Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d24cb0e6e | |||
| 778fd1898e | |||
| 45bfcb9e71 | |||
| aa7b5acfcd | |||
| 36e352afa7 | |||
| 2d86e97a7e | |||
| edadeaf495 | |||
| f9885130b4 | |||
| f414df3a56 | |||
| c0d25df311 | |||
| 10e36188da | |||
| 6a3102f9d4 | |||
| 75d3eaa0e4 | |||
| 802c7acb81 | |||
| 541cd732e8 | |||
| 4d119bb62a | |||
| 878c196738 | |||
| 50dd67c680 | |||
| aea4a90f0e | |||
| 897dc3a2bb | |||
| 4b5a88d714 | |||
| b1be86ef96 | |||
| ae7687cdc5 | |||
| c730f6cc0b | |||
| d993a3f450 | |||
| 1dfcc2ffc3 | |||
| 5b2c59559a | |||
| 087e74d4d7 | |||
| 9be83728a6 | |||
| 9397767513 | |||
| 9662e3218a | |||
| 0824ba6a9d | |||
| 42c076d349 | |||
| 0e2a53eab2 | |||
| eaa7e2db67 | |||
| 4e356098d2 | |||
| de24315978 | |||
| 20cb706e03 | |||
| d7a3468246 | |||
| f2d655529a | |||
| 27f4dba5ce | |||
| 8443998dc3 |
@@ -14,6 +14,7 @@ from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from hermes_cli.config import get_env_value
|
||||
import hermes_cli.auth as auth_mod
|
||||
from hermes_cli.auth import (
|
||||
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
||||
@@ -1273,7 +1274,8 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
def _is_source_suppressed(_p, _s): # type: ignore[misc]
|
||||
return False
|
||||
if provider == "openrouter":
|
||||
token = os.getenv("OPENROUTER_API_KEY", "").strip()
|
||||
# Check both os.environ and ~/.hermes/.env file
|
||||
token = (get_env_value("OPENROUTER_API_KEY") or "").strip()
|
||||
if token:
|
||||
source = "env:OPENROUTER_API_KEY"
|
||||
if _is_source_suppressed(provider, source):
|
||||
@@ -1299,7 +1301,7 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
|
||||
env_url = ""
|
||||
if pconfig.base_url_env_var:
|
||||
env_url = os.getenv(pconfig.base_url_env_var, "").strip().rstrip("/")
|
||||
env_url = (get_env_value(pconfig.base_url_env_var) or "").strip().rstrip("/")
|
||||
|
||||
env_vars = list(pconfig.api_key_env_vars)
|
||||
if provider == "anthropic":
|
||||
@@ -1310,7 +1312,8 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
]
|
||||
|
||||
for env_var in env_vars:
|
||||
token = os.getenv(env_var, "").strip()
|
||||
# Check both os.environ and ~/.hermes/.env file
|
||||
token = (get_env_value(env_var) or "").strip()
|
||||
if not token:
|
||||
continue
|
||||
source = f"env:{env_var}"
|
||||
|
||||
@@ -329,7 +329,7 @@ def build_skill_invocation_message(
|
||||
|
||||
loaded_skill, skill_dir, skill_name = loaded
|
||||
activation_note = (
|
||||
f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want '
|
||||
f'[IMPORTANT: The user has invoked the "{skill_name}" skill, indicating they want '
|
||||
"you to follow its instructions. The full skill content is loaded below.]"
|
||||
)
|
||||
return _build_skill_message(
|
||||
@@ -368,7 +368,7 @@ def build_preloaded_skills_prompt(
|
||||
|
||||
loaded_skill, skill_dir, skill_name = loaded
|
||||
activation_note = (
|
||||
f'[SYSTEM: The user launched this CLI session with the "{skill_name}" skill '
|
||||
f'[IMPORTANT: The user launched this CLI session with the "{skill_name}" skill '
|
||||
"preloaded. Treat its instructions as active guidance for the duration of this "
|
||||
"session unless the user overrides them.]"
|
||||
)
|
||||
|
||||
@@ -1378,7 +1378,7 @@ def _resolve_attachment_path(raw_path: str) -> Path | None:
|
||||
|
||||
|
||||
def _format_process_notification(evt: dict) -> "str | None":
|
||||
"""Format a process notification event into a [SYSTEM: ...] message.
|
||||
"""Format a process notification event into a [IMPORTANT: ...] message.
|
||||
|
||||
Handles both completion events (notify_on_complete) and watch pattern
|
||||
match events from the unified completion_queue.
|
||||
@@ -1388,14 +1388,14 @@ def _format_process_notification(evt: dict) -> "str | None":
|
||||
_cmd = evt.get("command", "unknown")
|
||||
|
||||
if evt_type == "watch_disabled":
|
||||
return f"[SYSTEM: {evt.get('message', '')}]"
|
||||
return f"[IMPORTANT: {evt.get('message', '')}]"
|
||||
|
||||
if evt_type == "watch_match":
|
||||
_pat = evt.get("pattern", "?")
|
||||
_out = evt.get("output", "")
|
||||
_sup = evt.get("suppressed", 0)
|
||||
text = (
|
||||
f"[SYSTEM: Background process {_sid} matched "
|
||||
f"[IMPORTANT: Background process {_sid} matched "
|
||||
f"watch pattern \"{_pat}\".\n"
|
||||
f"Command: {_cmd}\n"
|
||||
f"Matched output:\n{_out}"
|
||||
@@ -1409,7 +1409,7 @@ def _format_process_notification(evt: dict) -> "str | None":
|
||||
_exit = evt.get("exit_code", "?")
|
||||
_out = evt.get("output", "")
|
||||
return (
|
||||
f"[SYSTEM: Background process {_sid} completed "
|
||||
f"[IMPORTANT: Background process {_sid} completed "
|
||||
f"(exit code {_exit}).\n"
|
||||
f"Command: {_cmd}\n"
|
||||
f"Output:\n{_out}]"
|
||||
@@ -4915,6 +4915,12 @@ class HermesCLI:
|
||||
if self.agent:
|
||||
self.agent.session_id = new_session_id
|
||||
self.agent.session_start = now
|
||||
# Redirect the JSON session log to the new branch session file so
|
||||
# messages written after branching land in the correct file.
|
||||
if hasattr(self.agent, "session_log_file") and hasattr(self.agent, "logs_dir"):
|
||||
self.agent.session_log_file = (
|
||||
self.agent.logs_dir / f"session_{new_session_id}.json"
|
||||
)
|
||||
self.agent.reset_session_state()
|
||||
if hasattr(self.agent, "_last_flushed_db_idx"):
|
||||
self.agent._last_flushed_db_idx = len(self.conversation_history)
|
||||
@@ -6307,6 +6313,12 @@ class HermesCLI:
|
||||
turn_route = self._resolve_turn_agent_config(prompt)
|
||||
|
||||
def run_background():
|
||||
set_sudo_password_callback(self._sudo_password_callback)
|
||||
set_approval_callback(self._approval_callback)
|
||||
try:
|
||||
set_secret_capture_callback(self._secret_capture_callback)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
bg_agent = AIAgent(
|
||||
model=turn_route["model"],
|
||||
@@ -6404,6 +6416,12 @@ class HermesCLI:
|
||||
print()
|
||||
_cprint(f" ❌ Background task #{task_num} failed: {e}")
|
||||
finally:
|
||||
try:
|
||||
set_sudo_password_callback(None)
|
||||
set_approval_callback(None)
|
||||
set_secret_capture_callback(None)
|
||||
except Exception:
|
||||
pass
|
||||
self._background_tasks.pop(task_id, None)
|
||||
# Clear spinner only if no foreground agent owns it
|
||||
if not self._agent_running:
|
||||
@@ -7217,7 +7235,7 @@ class HermesCLI:
|
||||
change_detail = ". ".join(change_parts) + ". " if change_parts else ""
|
||||
self.conversation_history.append({
|
||||
"role": "user",
|
||||
"content": f"[SYSTEM: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]",
|
||||
"content": f"[IMPORTANT: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]",
|
||||
})
|
||||
|
||||
# Persist session immediately so the session log reflects the
|
||||
@@ -9841,7 +9859,7 @@ class HermesCLI:
|
||||
status = cli_ref._command_status or "Processing command..."
|
||||
return f"{frame} {status}"
|
||||
if cli_ref._agent_running:
|
||||
return "type a message + Enter to interrupt, Ctrl+C to cancel"
|
||||
return "msg=interrupt · /queue · /bg · /steer · Ctrl+C cancel"
|
||||
if cli_ref._voice_mode:
|
||||
return "type or Ctrl+B to record"
|
||||
return ""
|
||||
|
||||
+3
-3
@@ -715,7 +715,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
# Always prepend cron execution guidance so the agent knows how
|
||||
# delivery works and can suppress delivery when appropriate.
|
||||
cron_hint = (
|
||||
"[SYSTEM: You are running as a scheduled cron job. "
|
||||
"[IMPORTANT: You are running as a scheduled cron job. "
|
||||
"DELIVERY: Your final response will be automatically delivered "
|
||||
"to the user — do NOT use send_message or try to deliver "
|
||||
"the output yourself. Just produce your report/output as your "
|
||||
@@ -751,7 +751,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
parts.append("")
|
||||
parts.extend(
|
||||
[
|
||||
f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]',
|
||||
f'[IMPORTANT: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]',
|
||||
"",
|
||||
content,
|
||||
]
|
||||
@@ -759,7 +759,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
|
||||
if skipped:
|
||||
notice = (
|
||||
f"[SYSTEM: The following skill(s) were listed for this job but could not be found "
|
||||
f"[IMPORTANT: The following skill(s) were listed for this job but could not be found "
|
||||
f"and were skipped: {', '.join(skipped)}. "
|
||||
f"Start your response with a brief notice so the user is aware, e.g.: "
|
||||
f"'⚠️ Skill(s) not found and skipped: {', '.join(skipped)}']"
|
||||
|
||||
@@ -57,7 +57,7 @@ def _session_entry_name(origin: Dict[str, Any]) -> str:
|
||||
# Build / refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
|
||||
async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Build a channel directory from connected platform adapters and session data.
|
||||
|
||||
@@ -72,7 +72,7 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
|
||||
if platform == Platform.DISCORD:
|
||||
platforms["discord"] = _build_discord(adapter)
|
||||
elif platform == Platform.SLACK:
|
||||
platforms["slack"] = _build_slack(adapter)
|
||||
platforms["slack"] = await _build_slack(adapter)
|
||||
except Exception as e:
|
||||
logger.warning("Channel directory: failed to build %s: %s", platform.value, e)
|
||||
|
||||
@@ -136,21 +136,66 @@ def _build_discord(adapter) -> List[Dict[str, str]]:
|
||||
return channels
|
||||
|
||||
|
||||
def _build_slack(adapter) -> List[Dict[str, str]]:
|
||||
"""List Slack channels the bot has joined."""
|
||||
# Slack adapter may expose a web client
|
||||
client = getattr(adapter, "_app", None) or getattr(adapter, "_client", None)
|
||||
if not client:
|
||||
async def _build_slack(adapter) -> List[Dict[str, Any]]:
|
||||
"""List Slack channels the bot has joined across all workspaces.
|
||||
|
||||
Uses ``users.conversations`` against each workspace's web client. Pulls
|
||||
public + private channels the bot is a member of, then merges in DMs
|
||||
discovered from session history (IMs aren't useful to enumerate
|
||||
proactively).
|
||||
"""
|
||||
team_clients = getattr(adapter, "_team_clients", None) or {}
|
||||
if not team_clients:
|
||||
return _build_from_sessions("slack")
|
||||
|
||||
try:
|
||||
from tools.send_message_tool import _send_slack # noqa: F401
|
||||
# Use the Slack Web API directly if available
|
||||
except Exception:
|
||||
pass
|
||||
channels: List[Dict[str, Any]] = []
|
||||
seen_ids: set = set()
|
||||
|
||||
# Fallback to session data
|
||||
return _build_from_sessions("slack")
|
||||
for team_id, client in team_clients.items():
|
||||
try:
|
||||
cursor: Optional[str] = None
|
||||
for _page in range(20): # safety cap on pagination
|
||||
response = await client.users_conversations(
|
||||
types="public_channel,private_channel",
|
||||
exclude_archived=True,
|
||||
limit=200,
|
||||
cursor=cursor,
|
||||
)
|
||||
if not response.get("ok"):
|
||||
logger.warning(
|
||||
"Channel directory: users.conversations not ok for team %s: %s",
|
||||
team_id,
|
||||
response.get("error", "unknown"),
|
||||
)
|
||||
break
|
||||
for ch in response.get("channels", []):
|
||||
cid = ch.get("id")
|
||||
name = ch.get("name")
|
||||
if not cid or not name or cid in seen_ids:
|
||||
continue
|
||||
seen_ids.add(cid)
|
||||
channels.append({
|
||||
"id": cid,
|
||||
"name": name,
|
||||
"type": "private" if ch.get("is_private") else "channel",
|
||||
})
|
||||
cursor = (response.get("response_metadata") or {}).get("next_cursor")
|
||||
if not cursor:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Channel directory: failed to list Slack channels for team %s: %s",
|
||||
team_id, e,
|
||||
)
|
||||
continue
|
||||
|
||||
# Merge in DM/group entries discovered from session history.
|
||||
for entry in _build_from_sessions("slack"):
|
||||
if entry.get("id") not in seen_ids:
|
||||
channels.append(entry)
|
||||
seen_ids.add(entry.get("id"))
|
||||
|
||||
return channels
|
||||
|
||||
|
||||
def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:
|
||||
@@ -223,6 +268,14 @@ def resolve_channel_name(platform_name: str, name: str) -> Optional[str]:
|
||||
if not channels:
|
||||
return None
|
||||
|
||||
# 0. Exact ID match — case-sensitive, no normalization. Lets callers pass
|
||||
# raw platform IDs (e.g. Slack "C0B0QV5434G") even when the format guard
|
||||
# in _parse_target_ref hasn't recognized them as explicit.
|
||||
raw = name.strip()
|
||||
for ch in channels:
|
||||
if ch.get("id") == raw:
|
||||
return ch["id"]
|
||||
|
||||
query = _normalize_channel_query(name)
|
||||
|
||||
# 1. Exact name match, including the display labels shown by send_message(action="list")
|
||||
|
||||
@@ -570,6 +570,8 @@ def load_gateway_config() -> GatewayConfig:
|
||||
)
|
||||
if "reply_prefix" in platform_cfg:
|
||||
bridged["reply_prefix"] = platform_cfg["reply_prefix"]
|
||||
if "reply_in_thread" in platform_cfg:
|
||||
bridged["reply_in_thread"] = platform_cfg["reply_in_thread"]
|
||||
if "require_mention" in platform_cfg:
|
||||
bridged["require_mention"] = platform_cfg["require_mention"]
|
||||
if "free_response_channels" in platform_cfg:
|
||||
@@ -609,6 +611,8 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if isinstance(slack_cfg, dict):
|
||||
if "require_mention" in slack_cfg and not os.getenv("SLACK_REQUIRE_MENTION"):
|
||||
os.environ["SLACK_REQUIRE_MENTION"] = str(slack_cfg["require_mention"]).lower()
|
||||
if "strict_mention" in slack_cfg and not os.getenv("SLACK_STRICT_MENTION"):
|
||||
os.environ["SLACK_STRICT_MENTION"] = str(slack_cfg["strict_mention"]).lower()
|
||||
if "allow_bots" in slack_cfg and not os.getenv("SLACK_ALLOW_BOTS"):
|
||||
os.environ["SLACK_ALLOW_BOTS"] = str(slack_cfg["allow_bots"]).lower()
|
||||
frc = slack_cfg.get("free_response_channels")
|
||||
|
||||
+295
-29
@@ -15,7 +15,7 @@ import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Optional, Any, Tuple
|
||||
from typing import Dict, Optional, Any, Tuple, List
|
||||
|
||||
try:
|
||||
from slack_bolt.async_app import AsyncApp
|
||||
@@ -55,6 +55,7 @@ class _ThreadContextCache:
|
||||
content: str
|
||||
fetched_at: float = field(default_factory=time.monotonic)
|
||||
message_count: int = 0
|
||||
parent_text: str = "" # Raw text of the thread parent (for reply_to_text injection)
|
||||
|
||||
|
||||
def check_slack_requirements() -> bool:
|
||||
@@ -120,6 +121,63 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
# clear them (chat_id → thread_ts).
|
||||
self._active_status_threads: Dict[str, str] = {}
|
||||
|
||||
def _describe_slack_api_error(self, response: Any, *, file_obj: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
"""Convert Slack API auth/permission failures into actionable user-facing text."""
|
||||
if response is None or not hasattr(response, "get"):
|
||||
return None
|
||||
|
||||
error = str(response.get("error", "") or "").strip()
|
||||
if not error:
|
||||
return None
|
||||
|
||||
file_label = str((file_obj or {}).get("name") or (file_obj or {}).get("id") or "this attachment")
|
||||
needed = str(response.get("needed", "") or "").strip()
|
||||
provided = str(response.get("provided", "") or "").strip()
|
||||
reinstall_hint = " Update the Slack app scopes/settings and reinstall the app to the workspace."
|
||||
provided_hint = f" Current bot scopes: {provided}." if provided else ""
|
||||
|
||||
if error == "missing_scope":
|
||||
needed_hint = f"Missing scope: {needed}." if needed else "Missing required Slack scope."
|
||||
return f"Slack attachment access failed for {file_label}. {needed_hint}{provided_hint}{reinstall_hint}"
|
||||
if error in {"not_authed", "invalid_auth", "account_inactive", "token_revoked"}:
|
||||
return f"Slack attachment access failed for {file_label} because the bot token is not authorized ({error}). Refresh the token/reinstall the app."
|
||||
if error in {"file_not_found", "file_deleted"}:
|
||||
return f"Slack attachment {file_label} is no longer available ({error})."
|
||||
if error in {"access_denied", "file_access_denied", "no_permission", "not_allowed_token_type", "restricted_action"}:
|
||||
return f"Slack attachment access failed for {file_label} because the bot does not have permission ({error}). Check workspace permissions/scopes and reinstall if needed."
|
||||
return None
|
||||
|
||||
def _describe_slack_download_failure(self, exc: Exception, *, file_obj: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
"""Translate Slack download exceptions into user-facing attachment diagnostics."""
|
||||
file_label = str((file_obj or {}).get("name") or (file_obj or {}).get("id") or "this attachment")
|
||||
|
||||
response = getattr(exc, "response", None)
|
||||
api_detail = self._describe_slack_api_error(response, file_obj=file_obj)
|
||||
if api_detail:
|
||||
return api_detail
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except Exception: # pragma: no cover
|
||||
httpx = None
|
||||
|
||||
if httpx is not None and isinstance(exc, httpx.HTTPStatusError):
|
||||
status = exc.response.status_code
|
||||
if status == 401:
|
||||
return f"Slack attachment access failed for {file_label} with HTTP 401. The bot token is not authorized for this file."
|
||||
if status == 403:
|
||||
return f"Slack attachment access failed for {file_label} with HTTP 403. The bot likely lacks permission or scope to read this file."
|
||||
if status == 404:
|
||||
return f"Slack attachment {file_label} returned HTTP 404 and is no longer reachable."
|
||||
|
||||
message = str(exc)
|
||||
if "Slack returned HTML instead of media" in message or "non-image data" in message:
|
||||
return (
|
||||
f"Slack attachment access failed for {file_label}: Slack returned an HTML/login or non-media response. "
|
||||
"This usually means a scope, auth, or file-permission problem."
|
||||
)
|
||||
return None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Slack via Socket Mode."""
|
||||
if not SLACK_AVAILABLE:
|
||||
@@ -207,8 +265,31 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
async def handle_assistant_thread_context_changed(event, say):
|
||||
await self._handle_assistant_thread_lifecycle_event(event)
|
||||
|
||||
# Register slash command handler
|
||||
@self._app.command("/hermes")
|
||||
# Register slash command handler(s)
|
||||
#
|
||||
# Every gateway command from COMMAND_REGISTRY is a native Slack
|
||||
# slash, matching Discord and Telegram's model (e.g. /btw, /stop,
|
||||
# /model work directly without /hermes prefix). A single regex
|
||||
# matcher dispatches all of them to one handler so we don't need
|
||||
# N identical @app.command() decorators.
|
||||
#
|
||||
# The slash commands must ALSO be declared in the Slack app
|
||||
# manifest (see `hermes slack manifest`). In Socket Mode, Slack
|
||||
# routes the command event through the socket regardless of the
|
||||
# manifest's request URL, but it will not deliver an event for
|
||||
# a slash command the manifest doesn't declare.
|
||||
from hermes_cli.commands import slack_native_slashes
|
||||
import re as _re
|
||||
|
||||
_slash_names = [name for name, _d, _h in slack_native_slashes()]
|
||||
if _slash_names:
|
||||
_slash_pattern = _re.compile(
|
||||
r"^/(?:" + "|".join(_re.escape(n) for n in _slash_names) + r")$"
|
||||
)
|
||||
else: # pragma: no cover - registry always non-empty
|
||||
_slash_pattern = _re.compile(r"^/hermes$")
|
||||
|
||||
@self._app.command(_slash_pattern)
|
||||
async def handle_hermes_command(ack, command):
|
||||
await ack()
|
||||
await self._handle_slash_command(command)
|
||||
@@ -427,8 +508,18 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
# When reply_in_thread is disabled (default: True for backward compat),
|
||||
# only thread messages that are already part of an existing thread.
|
||||
# For top-level channel messages, the inbound handler sets
|
||||
# metadata.thread_id to the message's own ts as a session-keying
|
||||
# fallback (see the `thread_ts = event.get("thread_ts") or ts` branch),
|
||||
# so metadata alone can't distinguish a real thread reply from a
|
||||
# top-level message. reply_to is the incoming message's own id, so
|
||||
# when thread_id == reply_to the "thread" is synthetic and we reply
|
||||
# directly in the channel instead.
|
||||
if not self.config.extra.get("reply_in_thread", True):
|
||||
existing_thread = (metadata or {}).get("thread_id") or (metadata or {}).get("thread_ts")
|
||||
md = metadata or {}
|
||||
existing_thread = md.get("thread_id") or md.get("thread_ts")
|
||||
if existing_thread and reply_to and existing_thread == reply_to:
|
||||
existing_thread = None
|
||||
return existing_thread or None
|
||||
|
||||
if metadata:
|
||||
@@ -1100,6 +1191,8 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
pass # Free-response channel — always process
|
||||
elif not self._slack_require_mention():
|
||||
pass # Mention requirement disabled globally for Slack
|
||||
elif self._slack_strict_mention() and not is_mentioned:
|
||||
return # Strict mode: ignore until @-mentioned again
|
||||
elif not is_mentioned:
|
||||
reply_to_bot_thread = (
|
||||
is_thread_reply and event_thread_ts in self._bot_message_ts
|
||||
@@ -1122,8 +1215,11 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if is_mentioned:
|
||||
# Strip the bot mention from the text
|
||||
text = text.replace(f"<@{bot_uid}>", "").strip()
|
||||
# Register this thread so all future messages auto-trigger the bot
|
||||
if event_thread_ts:
|
||||
# Register this thread so all future messages auto-trigger the bot.
|
||||
# Skipped in strict mode: strict_mention=true bots must be
|
||||
# re-mentioned every turn, so remembering the thread would
|
||||
# defeat the feature (and re-enable agent-to-agent ack loops).
|
||||
if event_thread_ts and not self._slack_strict_mention():
|
||||
self._mentioned_threads.add(event_thread_ts)
|
||||
if len(self._mentioned_threads) > self._MENTIONED_THREADS_MAX:
|
||||
to_remove = list(self._mentioned_threads)[:self._MENTIONED_THREADS_MAX // 2]
|
||||
@@ -1154,8 +1250,43 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
# Handle file attachments
|
||||
media_urls = []
|
||||
media_types = []
|
||||
attachment_notices: List[str] = []
|
||||
files = event.get("files", [])
|
||||
for f in files:
|
||||
# Slack Connect channels return stub file objects with
|
||||
# file_access="check_file_info" and no URL fields. We must
|
||||
# call files.info to retrieve the full object (including url_private_download)
|
||||
# before we can download it.
|
||||
# https://docs.slack.dev/reference/objects/file-object/#slack_connect_files
|
||||
if f.get("file_access") == "check_file_info":
|
||||
file_id = f.get("id")
|
||||
if not file_id:
|
||||
continue
|
||||
try:
|
||||
info_resp = await self._get_client(channel_id).files_info(file=file_id)
|
||||
if info_resp.get("ok"):
|
||||
f = info_resp["file"]
|
||||
else:
|
||||
detail = self._describe_slack_api_error(info_resp, file_obj=f)
|
||||
if detail:
|
||||
attachment_notices.append(detail)
|
||||
logger.warning("[Slack] %s", detail)
|
||||
else:
|
||||
logger.warning(
|
||||
"[Slack] files.info failed for %s: %s",
|
||||
file_id, info_resp.get("error"),
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
response = getattr(e, "response", None)
|
||||
detail = self._describe_slack_api_error(response, file_obj=f)
|
||||
if detail:
|
||||
attachment_notices.append(detail)
|
||||
logger.warning("[Slack] %s", detail)
|
||||
else:
|
||||
logger.warning("[Slack] files.info error for %s: %s", file_id, e, exc_info=True)
|
||||
continue
|
||||
|
||||
mimetype = f.get("mimetype", "unknown")
|
||||
url = f.get("url_private_download") or f.get("url_private", "")
|
||||
if mimetype.startswith("image/") and url:
|
||||
@@ -1169,7 +1300,12 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
media_types.append(mimetype)
|
||||
msg_type = MessageType.PHOTO
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.warning("[Slack] Failed to cache image from %s: %s", url, e, exc_info=True)
|
||||
detail = self._describe_slack_download_failure(e, file_obj=f)
|
||||
if detail:
|
||||
attachment_notices.append(detail)
|
||||
logger.warning("[Slack] %s", detail)
|
||||
else:
|
||||
logger.warning("[Slack] Failed to cache image from %s: %s", url, e, exc_info=True)
|
||||
elif mimetype.startswith("audio/") and url:
|
||||
try:
|
||||
ext = "." + mimetype.split("/")[-1].split(";")[0]
|
||||
@@ -1180,7 +1316,12 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
media_types.append(mimetype)
|
||||
msg_type = MessageType.VOICE
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.warning("[Slack] Failed to cache audio from %s: %s", url, e, exc_info=True)
|
||||
detail = self._describe_slack_download_failure(e, file_obj=f)
|
||||
if detail:
|
||||
attachment_notices.append(detail)
|
||||
logger.warning("[Slack] %s", detail)
|
||||
else:
|
||||
logger.warning("[Slack] Failed to cache audio from %s: %s", url, e, exc_info=True)
|
||||
elif url:
|
||||
# Try to handle as a document attachment
|
||||
try:
|
||||
@@ -1232,7 +1373,16 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
pass # Binary content, skip injection
|
||||
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.warning("[Slack] Failed to cache document from %s: %s", url, e, exc_info=True)
|
||||
detail = self._describe_slack_download_failure(e, file_obj=f)
|
||||
if detail:
|
||||
attachment_notices.append(detail)
|
||||
logger.warning("[Slack] %s", detail)
|
||||
else:
|
||||
logger.warning("[Slack] Failed to cache document from %s: %s", url, e, exc_info=True)
|
||||
|
||||
if attachment_notices:
|
||||
notice_block = "[Slack attachment notice]\n" + "\n".join(f"- {n}" for n in attachment_notices)
|
||||
text = f"{notice_block}\n\n{text}" if text else notice_block
|
||||
|
||||
# Resolve user display name (cached after first lookup)
|
||||
user_name = await self._resolve_user_name(user_id, chat_id=channel_id)
|
||||
@@ -1253,6 +1403,22 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
self.config.extra, channel_id, None,
|
||||
)
|
||||
|
||||
# Extract reply context if this message is a thread reply.
|
||||
# Mirrors the Telegram/Discord implementations so that gateway.run
|
||||
# can inject a `[Replying to: "..."]` prefix when the parent is not
|
||||
# already in the session history. Uses the thread-context cache when
|
||||
# available to avoid redundant conversations.replies calls.
|
||||
reply_to_text = None
|
||||
if thread_ts and thread_ts != ts:
|
||||
try:
|
||||
reply_to_text = await self._fetch_thread_parent_text(
|
||||
channel_id=channel_id,
|
||||
thread_ts=thread_ts,
|
||||
team_id=team_id,
|
||||
) or None
|
||||
except Exception: # pragma: no cover - defensive
|
||||
reply_to_text = None
|
||||
|
||||
msg_event = MessageEvent(
|
||||
text=text,
|
||||
message_type=msg_type,
|
||||
@@ -1263,6 +1429,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
media_types=media_types,
|
||||
reply_to_message_id=thread_ts if thread_ts != ts else None,
|
||||
channel_prompt=_channel_prompt,
|
||||
reply_to_text=reply_to_text,
|
||||
)
|
||||
|
||||
# Only react when bot is directly addressed (DM or @mention).
|
||||
@@ -1470,7 +1637,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
Returns a formatted string with prior thread history, or empty string
|
||||
on failure or if the thread has no prior messages.
|
||||
"""
|
||||
cache_key = f"{channel_id}:{thread_ts}"
|
||||
cache_key = f"{channel_id}:{thread_ts}:{team_id}"
|
||||
now = time.monotonic()
|
||||
cached = self._thread_context_cache.get(cache_key)
|
||||
if cached and (now - cached.fetched_at) < self._THREAD_CACHE_TTL:
|
||||
@@ -1517,14 +1684,37 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
|
||||
bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
|
||||
context_parts = []
|
||||
parent_text = ""
|
||||
for msg in messages:
|
||||
msg_ts = msg.get("ts", "")
|
||||
# Exclude the current triggering message — it will be delivered
|
||||
# as the user message itself, so including it here would duplicate it.
|
||||
if msg_ts == current_ts:
|
||||
continue
|
||||
# Exclude our own bot messages to avoid circular context.
|
||||
if msg.get("bot_id") or msg.get("subtype") == "bot_message":
|
||||
|
||||
is_parent = msg_ts == thread_ts
|
||||
is_bot = bool(msg.get("bot_id")) or msg.get("subtype") == "bot_message"
|
||||
msg_user = msg.get("user", "")
|
||||
|
||||
# Identify "our own" bot for this workspace (multi-workspace safe).
|
||||
msg_team = msg.get("team") or team_id
|
||||
self_bot_uid = (
|
||||
self._team_bot_user_ids.get(msg_team)
|
||||
if msg_team
|
||||
else None
|
||||
) or self._bot_user_id
|
||||
|
||||
# Exclude only our own prior bot replies (circular context).
|
||||
# Keep:
|
||||
# - the thread parent even if it was posted by a bot
|
||||
# (e.g. a cron job summary we are now replying to);
|
||||
# - other bots' child messages (useful third-party context).
|
||||
if (
|
||||
is_bot
|
||||
and not is_parent
|
||||
and self_bot_uid
|
||||
and msg_user == self_bot_uid
|
||||
):
|
||||
continue
|
||||
|
||||
msg_text = msg.get("text", "").strip()
|
||||
@@ -1535,11 +1725,15 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if bot_uid:
|
||||
msg_text = msg_text.replace(f"<@{bot_uid}>", "").strip()
|
||||
|
||||
msg_user = msg.get("user", "unknown")
|
||||
is_parent = msg_ts == thread_ts
|
||||
prefix = "[thread parent] " if is_parent else ""
|
||||
name = await self._resolve_user_name(msg_user, chat_id=channel_id)
|
||||
display_user = msg_user or "unknown"
|
||||
# Prefer the bot's own name when the message is a bot post.
|
||||
if is_bot and not display_user:
|
||||
display_user = msg.get("username") or "bot"
|
||||
name = await self._resolve_user_name(display_user, chat_id=channel_id)
|
||||
context_parts.append(f"{prefix}{name}: {msg_text}")
|
||||
if is_parent:
|
||||
parent_text = msg_text
|
||||
|
||||
content = ""
|
||||
if context_parts:
|
||||
@@ -1553,6 +1747,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
content=content,
|
||||
fetched_at=now,
|
||||
message_count=len(context_parts),
|
||||
parent_text=parent_text,
|
||||
)
|
||||
return content
|
||||
|
||||
@@ -1560,8 +1755,62 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
logger.warning("[Slack] Failed to fetch thread context: %s", e)
|
||||
return ""
|
||||
|
||||
async def _fetch_thread_parent_text(
|
||||
self, channel_id: str, thread_ts: str, team_id: str = "",
|
||||
) -> str:
|
||||
"""Return the raw text of the thread parent message (for reply_to_text).
|
||||
|
||||
Uses the same per-thread cache as :meth:`_fetch_thread_context` to avoid
|
||||
hitting ``conversations.replies`` twice. Falls back to a cheap single-
|
||||
message fetch (``limit=1, inclusive=True``) when the cache is cold.
|
||||
|
||||
Returns empty string on any failure — callers should treat an empty
|
||||
return as "no parent context to inject".
|
||||
"""
|
||||
cache_key = f"{channel_id}:{thread_ts}:{team_id}"
|
||||
now = time.monotonic()
|
||||
cached = self._thread_context_cache.get(cache_key)
|
||||
if cached and (now - cached.fetched_at) < self._THREAD_CACHE_TTL:
|
||||
return cached.parent_text
|
||||
|
||||
try:
|
||||
client = self._get_client(channel_id)
|
||||
result = await client.conversations_replies(
|
||||
channel=channel_id,
|
||||
ts=thread_ts,
|
||||
limit=1,
|
||||
inclusive=True,
|
||||
)
|
||||
messages = result.get("messages", []) if result else []
|
||||
if not messages:
|
||||
return ""
|
||||
parent = messages[0]
|
||||
if parent.get("ts", "") != thread_ts:
|
||||
return ""
|
||||
bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
|
||||
text = (parent.get("text") or "").strip()
|
||||
if bot_uid:
|
||||
text = text.replace(f"<@{bot_uid}>", "").strip()
|
||||
return text
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("[Slack] Failed to fetch thread parent text: %s", exc)
|
||||
return ""
|
||||
|
||||
async def _handle_slash_command(self, command: dict) -> None:
|
||||
"""Handle /hermes slash command."""
|
||||
"""Handle Slack slash commands.
|
||||
|
||||
Every gateway command in COMMAND_REGISTRY is registered as a native
|
||||
Slack slash (``/btw``, ``/stop``, ``/model``, etc.), matching the
|
||||
Discord and Telegram model. The slash name itself is the command;
|
||||
any text after it is the argument list.
|
||||
|
||||
The legacy ``/hermes <subcommand> [args]`` form is preserved for
|
||||
backward compatibility with older workspace manifests and for users
|
||||
who want a single entry point for free-form questions (``/hermes
|
||||
what's the weather`` — non-slash text is treated as a regular
|
||||
message).
|
||||
"""
|
||||
slash_name = (command.get("command") or "").lstrip("/").strip()
|
||||
text = command.get("text", "").strip()
|
||||
user_id = command.get("user_id", "")
|
||||
channel_id = command.get("channel_id", "")
|
||||
@@ -1571,20 +1820,25 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if team_id and channel_id:
|
||||
self._channel_team[channel_id] = team_id
|
||||
|
||||
# Map subcommands to gateway commands — derived from central registry.
|
||||
# Also keep "compact" as a Slack-specific alias for /compress.
|
||||
from hermes_cli.commands import slack_subcommand_map
|
||||
subcommand_map = slack_subcommand_map()
|
||||
subcommand_map["compact"] = "/compress"
|
||||
first_word = text.split()[0] if text else ""
|
||||
if first_word in subcommand_map:
|
||||
# Preserve arguments after the subcommand
|
||||
rest = text[len(first_word):].strip()
|
||||
text = f"{subcommand_map[first_word]} {rest}".strip() if rest else subcommand_map[first_word]
|
||||
elif text:
|
||||
pass # Treat as a regular question
|
||||
if slash_name in ("hermes", ""):
|
||||
# Legacy /hermes <subcommand> [args] routing + free-form questions.
|
||||
# Empty slash_name falls into this branch for backward compat
|
||||
# with any caller that didn't populate command["command"].
|
||||
from hermes_cli.commands import slack_subcommand_map
|
||||
subcommand_map = slack_subcommand_map()
|
||||
subcommand_map["compact"] = "/compress"
|
||||
first_word = text.split()[0] if text else ""
|
||||
if first_word in subcommand_map:
|
||||
rest = text[len(first_word):].strip()
|
||||
text = f"{subcommand_map[first_word]} {rest}".strip() if rest else subcommand_map[first_word]
|
||||
elif text:
|
||||
pass # Treat as a regular question
|
||||
else:
|
||||
text = "/help"
|
||||
else:
|
||||
text = "/help"
|
||||
# Native slash — /<slash_name> [args]. Route directly through the
|
||||
# gateway command dispatcher by prepending the slash.
|
||||
text = f"/{slash_name} {text}".strip()
|
||||
|
||||
source = self.build_source(
|
||||
chat_id=channel_id,
|
||||
@@ -1732,6 +1986,18 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
return bool(configured)
|
||||
return os.getenv("SLACK_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off")
|
||||
|
||||
def _slack_strict_mention(self) -> bool:
|
||||
"""When true, channel threads require an explicit @-mention on every
|
||||
message. Disables all auto-triggers (mentioned-thread memory,
|
||||
bot-message follow-up, session-presence). Defaults to False.
|
||||
"""
|
||||
configured = self.config.extra.get("strict_mention")
|
||||
if configured is not None:
|
||||
if isinstance(configured, str):
|
||||
return configured.lower() in ("true", "1", "yes", "on")
|
||||
return bool(configured)
|
||||
return os.getenv("SLACK_STRICT_MENTION", "false").lower() in ("true", "1", "yes", "on")
|
||||
|
||||
def _slack_free_response_channels(self) -> set:
|
||||
"""Return channel IDs where no @mention is required."""
|
||||
raw = self.config.extra.get("free_response_channels")
|
||||
|
||||
+157
-19
@@ -591,20 +591,20 @@ def _parse_session_key(session_key: str) -> "dict | None":
|
||||
|
||||
|
||||
def _format_gateway_process_notification(evt: dict) -> "str | None":
|
||||
"""Format a watch pattern event from completion_queue into a [SYSTEM:] message."""
|
||||
"""Format a watch pattern event from completion_queue into a [IMPORTANT:] message."""
|
||||
evt_type = evt.get("type", "completion")
|
||||
_sid = evt.get("session_id", "unknown")
|
||||
_cmd = evt.get("command", "unknown")
|
||||
|
||||
if evt_type == "watch_disabled":
|
||||
return f"[SYSTEM: {evt.get('message', '')}]"
|
||||
return f"[IMPORTANT: {evt.get('message', '')}]"
|
||||
|
||||
if evt_type == "watch_match":
|
||||
_pat = evt.get("pattern", "?")
|
||||
_out = evt.get("output", "")
|
||||
_sup = evt.get("suppressed", 0)
|
||||
text = (
|
||||
f"[SYSTEM: Background process {_sid} matched "
|
||||
f"[IMPORTANT: Background process {_sid} matched "
|
||||
f"watch pattern \"{_pat}\".\n"
|
||||
f"Command: {_cmd}\n"
|
||||
f"Matched output:\n{_out}"
|
||||
@@ -682,6 +682,16 @@ class GatewayRunner:
|
||||
self._running_agents: Dict[str, Any] = {}
|
||||
self._running_agents_ts: Dict[str, float] = {} # start timestamp per session
|
||||
self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt
|
||||
# Overflow buffer for explicit /queue commands. The adapter-level
|
||||
# _pending_messages dict is a single slot per session (designed for
|
||||
# "next-turn" follow-ups where repeated sends collapse into one
|
||||
# event). /queue has different semantics: each invocation must
|
||||
# produce its own full agent turn, in FIFO order, with no merging.
|
||||
# When the slot is occupied, additional /queue items land here and
|
||||
# are promoted one-at-a-time after each run's drain. Cleared on
|
||||
# /new and /reset. /model and other mid-session operations
|
||||
# preserve the queue.
|
||||
self._queued_events: Dict[str, List[MessageEvent]] = {}
|
||||
self._busy_ack_ts: Dict[str, float] = {} # last busy-ack timestamp per session (debounce)
|
||||
self._session_run_generation: Dict[str, int] = {}
|
||||
|
||||
@@ -1204,6 +1214,76 @@ class GatewayRunner:
|
||||
def _queue_during_drain_enabled(self) -> bool:
|
||||
return self._restart_requested and self._busy_input_mode == "queue"
|
||||
|
||||
# -------- /queue FIFO helpers --------------------------------------
|
||||
# /queue must produce one full agent turn per invocation, in FIFO
|
||||
# order, with no merging. The adapter's _pending_messages dict is a
|
||||
# single "next-up" slot (shared with photo-burst follow-ups), so we
|
||||
# use it for the head of the queue and an overflow list for the
|
||||
# tail. Enqueue puts new items in the slot when free, otherwise in
|
||||
# the overflow. Promotion (called after each run's drain) moves the
|
||||
# next overflow item into the slot so the following recursion picks
|
||||
# it up. Clearing happens on /new and /reset via
|
||||
# _handle_reset_command.
|
||||
|
||||
def _enqueue_fifo(self, session_key: str, queued_event: "MessageEvent", adapter: Any) -> None:
|
||||
"""Append a /queue event to the FIFO chain for a session."""
|
||||
if adapter is None:
|
||||
return
|
||||
pending_slot = getattr(adapter, "_pending_messages", None)
|
||||
if pending_slot is None:
|
||||
return
|
||||
queued_events = getattr(self, "_queued_events", None)
|
||||
if queued_events is None:
|
||||
queued_events = {}
|
||||
self._queued_events = queued_events
|
||||
if session_key in pending_slot:
|
||||
queued_events.setdefault(session_key, []).append(queued_event)
|
||||
else:
|
||||
pending_slot[session_key] = queued_event
|
||||
|
||||
def _promote_queued_event(
|
||||
self,
|
||||
session_key: str,
|
||||
adapter: Any,
|
||||
pending_event: Optional["MessageEvent"],
|
||||
) -> Optional["MessageEvent"]:
|
||||
"""Promote the next overflow item after the slot was drained.
|
||||
|
||||
Called at the drain site after _dequeue_pending_event consumed
|
||||
(or failed to consume) the slot. If there's an overflow item:
|
||||
- When pending_event is None (slot was empty), return the
|
||||
overflow head as the new pending_event.
|
||||
- When pending_event already exists (slot was populated by an
|
||||
interrupt follow-up or similar), stage the overflow head in
|
||||
the slot so the NEXT recursion picks it up.
|
||||
Returns the (possibly updated) pending_event for drain to use.
|
||||
"""
|
||||
queued_events = getattr(self, "_queued_events", None)
|
||||
if not queued_events:
|
||||
return pending_event
|
||||
overflow = queued_events.get(session_key)
|
||||
if not overflow:
|
||||
return pending_event
|
||||
next_queued = overflow.pop(0)
|
||||
if not overflow:
|
||||
queued_events.pop(session_key, None)
|
||||
if pending_event is None:
|
||||
return next_queued
|
||||
if adapter is not None and hasattr(adapter, "_pending_messages"):
|
||||
adapter._pending_messages[session_key] = next_queued
|
||||
else:
|
||||
# No adapter — push back so we don't silently drop the item.
|
||||
queued_events.setdefault(session_key, []).insert(0, next_queued)
|
||||
return pending_event
|
||||
|
||||
def _queue_depth(self, session_key: str, *, adapter: Any = None) -> int:
|
||||
"""Total pending /queue items for a session — slot + overflow."""
|
||||
queued_events = getattr(self, "_queued_events", None) or {}
|
||||
depth = len(queued_events.get(session_key, []))
|
||||
if adapter is not None and session_key in getattr(adapter, "_pending_messages", {}):
|
||||
depth += 1
|
||||
return depth
|
||||
|
||||
def _update_runtime_status(self, gateway_state: Optional[str] = None, exit_reason: Optional[str] = None) -> None:
|
||||
try:
|
||||
from gateway.status import write_runtime_status
|
||||
@@ -2254,7 +2334,7 @@ class GatewayRunner:
|
||||
# Build initial channel directory for send_message name resolution
|
||||
try:
|
||||
from gateway.channel_directory import build_channel_directory
|
||||
directory = build_channel_directory(self.adapters)
|
||||
directory = await build_channel_directory(self.adapters)
|
||||
ch_count = sum(len(chs) for chs in directory.get("platforms", {}).values())
|
||||
logger.info("Channel directory built: %d target(s)", ch_count)
|
||||
except Exception as e:
|
||||
@@ -2538,7 +2618,7 @@ class GatewayRunner:
|
||||
# Rebuild channel directory with the new adapter
|
||||
try:
|
||||
from gateway.channel_directory import build_channel_directory
|
||||
build_channel_directory(self.adapters)
|
||||
await build_channel_directory(self.adapters)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
@@ -3416,7 +3496,10 @@ class GatewayRunner:
|
||||
# doesn't think an agent is still active.
|
||||
return await self._handle_reset_command(event)
|
||||
|
||||
# /queue <prompt> — queue without interrupting
|
||||
# /queue <prompt> — queue without interrupting.
|
||||
# Semantics: each /queue invocation produces its own full agent
|
||||
# turn, processed in FIFO order after the current run (and any
|
||||
# earlier /queue items) finishes. Messages are NOT merged.
|
||||
if event.get_command() in ("queue", "q"):
|
||||
queued_text = event.get_command_args().strip()
|
||||
if not queued_text:
|
||||
@@ -3430,8 +3513,11 @@ class GatewayRunner:
|
||||
message_id=event.message_id,
|
||||
channel_prompt=event.channel_prompt,
|
||||
)
|
||||
adapter._pending_messages[_quick_key] = queued_event
|
||||
return "Queued for the next turn."
|
||||
self._enqueue_fifo(_quick_key, queued_event, adapter)
|
||||
depth = self._queue_depth(_quick_key, adapter=self.adapters.get(source.platform))
|
||||
if depth <= 1:
|
||||
return "Queued for the next turn."
|
||||
return f"Queued for the next turn. ({depth} queued)"
|
||||
|
||||
# /steer <prompt> — inject mid-run after the next tool call.
|
||||
# Unlike /queue (turn boundary), /steer lands BETWEEN tool-call
|
||||
@@ -4232,7 +4318,7 @@ class GatewayRunner:
|
||||
if _loaded:
|
||||
_loaded_skill, _skill_dir, _display_name = _loaded
|
||||
_note = (
|
||||
f'[SYSTEM: The "{_display_name}" skill is auto-loaded. '
|
||||
f'[IMPORTANT: The "{_display_name}" skill is auto-loaded. '
|
||||
f"Follow its instructions for this session.]"
|
||||
)
|
||||
_part = _build_skill_message(_loaded_skill, _skill_dir, _note)
|
||||
@@ -4520,12 +4606,20 @@ class GatewayRunner:
|
||||
if not os.getenv(env_key):
|
||||
adapter = self.adapters.get(source.platform)
|
||||
if adapter:
|
||||
# Slack dispatches all Hermes commands through a single
|
||||
# parent slash command `/hermes`; bare `/sethome` is not
|
||||
# registered and would fail with "app did not respond".
|
||||
sethome_cmd = (
|
||||
"/hermes sethome"
|
||||
if source.platform == Platform.SLACK
|
||||
else "/sethome"
|
||||
)
|
||||
await adapter.send(
|
||||
source.chat_id,
|
||||
f"📬 No home channel is set for {platform_name.title()}. "
|
||||
f"A home channel is where Hermes delivers cron job results "
|
||||
f"and cross-platform messages.\n\n"
|
||||
f"Type /sethome to make this chat your home channel, "
|
||||
f"Type {sethome_cmd} to make this chat your home channel, "
|
||||
f"or ignore to skip."
|
||||
)
|
||||
|
||||
@@ -5058,6 +5152,13 @@ class GatewayRunner:
|
||||
self._cleanup_agent_resources(_old_agent)
|
||||
self._evict_cached_agent(session_key)
|
||||
|
||||
# Discard any /queue overflow for this session — /new is a
|
||||
# conversation-boundary operation, queued follow-ups from the
|
||||
# previous conversation must not bleed into the new one.
|
||||
_qe = getattr(self, "_queued_events", None)
|
||||
if _qe is not None:
|
||||
_qe.pop(session_key, None)
|
||||
|
||||
try:
|
||||
from tools.env_passthrough import clear_env_passthrough
|
||||
clear_env_passthrough()
|
||||
@@ -5165,6 +5266,10 @@ class GatewayRunner:
|
||||
session_key = session_entry.session_key
|
||||
is_running = session_key in self._running_agents
|
||||
|
||||
# Count pending /queue follow-ups (slot + overflow).
|
||||
adapter = self.adapters.get(source.platform) if source else None
|
||||
queue_depth = self._queue_depth(session_key, adapter=adapter)
|
||||
|
||||
title = None
|
||||
if self._session_db:
|
||||
try:
|
||||
@@ -5184,6 +5289,10 @@ class GatewayRunner:
|
||||
f"**Last Activity:** {session_entry.updated_at.strftime('%Y-%m-%d %H:%M')}",
|
||||
f"**Tokens:** {session_entry.total_tokens:,}",
|
||||
f"**Agent Running:** {'Yes ⚡' if is_running else 'No'}",
|
||||
])
|
||||
if queue_depth:
|
||||
lines.append(f"**Queued follow-ups:** {queue_depth}")
|
||||
lines.extend([
|
||||
"",
|
||||
f"**Connected Platforms:** {', '.join(connected_platforms)}",
|
||||
])
|
||||
@@ -7473,7 +7582,7 @@ class GatewayRunner:
|
||||
change_detail = ". ".join(change_parts) + ". " if change_parts else ""
|
||||
reload_msg = {
|
||||
"role": "user",
|
||||
"content": f"[SYSTEM: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]",
|
||||
"content": f"[IMPORTANT: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]",
|
||||
}
|
||||
try:
|
||||
session_entry = self.session_store.get_or_create_session(event.source)
|
||||
@@ -8412,7 +8521,7 @@ class GatewayRunner:
|
||||
from tools.ansi_strip import strip_ansi
|
||||
_out = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else ""
|
||||
synth_text = (
|
||||
f"[SYSTEM: Background process {session_id} completed "
|
||||
f"[IMPORTANT: Background process {session_id} completed "
|
||||
f"(exit code {session.exit_code}).\n"
|
||||
f"Command: {session.command}\n"
|
||||
f"Output:\n{_out}]"
|
||||
@@ -8722,6 +8831,25 @@ class GatewayRunner:
|
||||
with _lock:
|
||||
self._agent_cache.pop(session_key, None)
|
||||
|
||||
@staticmethod
|
||||
def _init_cached_agent_for_turn(agent: Any, interrupt_depth: int) -> None:
|
||||
"""Reset per-turn state on a cached agent before a new turn starts.
|
||||
|
||||
Both _last_activity_ts and _last_activity_desc are only reset for
|
||||
fresh external turns (depth 0); they are semantically paired —
|
||||
desc describes the activity *at* ts, so updating one without the
|
||||
other would make get_activity_summary() misleading.
|
||||
For interrupt-recursive turns both are preserved so the inactivity
|
||||
watchdog can accumulate stuck-turn idle time and fire the 30-min
|
||||
timeout (#15654). The depth-0 reset is still needed: a session
|
||||
idle for 29 min would otherwise trip the watchdog before the new
|
||||
turn makes its first API call (#9051).
|
||||
"""
|
||||
if interrupt_depth == 0:
|
||||
agent._last_activity_ts = time.time()
|
||||
agent._last_activity_desc = "starting new turn (cached)"
|
||||
agent._api_call_count = 0
|
||||
|
||||
def _release_evicted_agent_soft(self, agent: Any) -> None:
|
||||
"""Soft cleanup for cache-evicted agents — preserves session tool state.
|
||||
|
||||
@@ -9766,12 +9894,7 @@ class GatewayRunner:
|
||||
_cache.move_to_end(session_key)
|
||||
except KeyError:
|
||||
pass
|
||||
# Reset activity timestamp so the inactivity timeout
|
||||
# handler doesn't see stale idle time from the previous
|
||||
# turn and immediately kill this agent. (#9051)
|
||||
agent._last_activity_ts = time.time()
|
||||
agent._last_activity_desc = "starting new turn (cached)"
|
||||
agent._api_call_count = 0
|
||||
self._init_cached_agent_for_turn(agent, _interrupt_depth)
|
||||
logger.debug("Reusing cached agent for session %s", session_key)
|
||||
|
||||
if agent is None:
|
||||
@@ -10554,6 +10677,13 @@ class GatewayRunner:
|
||||
pending = None
|
||||
if result and adapter and session_key:
|
||||
pending_event = _dequeue_pending_event(adapter, session_key)
|
||||
# /queue overflow: after consuming the adapter's "next-up"
|
||||
# slot, promote the next queued event into it so the
|
||||
# recursive run's drain will see it. This keeps the slot
|
||||
# occupied for the full FIFO chain, which (a) preserves
|
||||
# order, and (b) causes any mid-chain /queue to correctly
|
||||
# route to overflow rather than jumping the queue.
|
||||
pending_event = self._promote_queued_event(session_key, adapter, pending_event)
|
||||
if result.get("interrupted") and not pending_event and result.get("interrupt_message"):
|
||||
interrupt_message = result.get("interrupt_message")
|
||||
if _is_control_interrupt_message(interrupt_message):
|
||||
@@ -10848,7 +10978,15 @@ def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, in
|
||||
if tick_count % CHANNEL_DIR_EVERY == 0 and adapters:
|
||||
try:
|
||||
from gateway.channel_directory import build_channel_directory
|
||||
build_channel_directory(adapters)
|
||||
if loop is not None:
|
||||
# build_channel_directory is async (Slack web calls), and
|
||||
# this ticker runs in a background thread. Schedule onto
|
||||
# the gateway event loop and wait briefly for completion
|
||||
# so refresh failures are still logged via the except.
|
||||
fut = asyncio.run_coroutine_threadsafe(
|
||||
build_channel_directory(adapters), loop
|
||||
)
|
||||
fut.result(timeout=30)
|
||||
except Exception as e:
|
||||
logger.debug("Channel directory refresh error: %s", e)
|
||||
|
||||
|
||||
+17
-1
@@ -467,11 +467,27 @@ def _resolve_api_key_provider_secret(
|
||||
pass
|
||||
return "", ""
|
||||
|
||||
from hermes_cli.config import get_env_value
|
||||
for env_var in pconfig.api_key_env_vars:
|
||||
val = os.getenv(env_var, "").strip()
|
||||
# Check both os.environ and ~/.hermes/.env file
|
||||
val = (get_env_value(env_var) or "").strip()
|
||||
if has_usable_secret(val):
|
||||
return val, env_var
|
||||
|
||||
# Fallback: try credential pool (e.g. zai key stored via auth.json)
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool(provider_id)
|
||||
if pool and pool.has_credentials():
|
||||
entry = pool.peek()
|
||||
if entry:
|
||||
key = getattr(entry, "access_token", "") or getattr(entry, "runtime_api_key", "")
|
||||
key = str(key).strip()
|
||||
if has_usable_secret(key):
|
||||
return key, f"credential_pool:{provider_id}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "", ""
|
||||
|
||||
|
||||
|
||||
@@ -806,6 +806,114 @@ def discord_skill_commands_by_category(
|
||||
return trimmed_categories, uncategorized, hidden
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slack native slash commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Slack slash command name constraints: lowercase a-z, 0-9, hyphens,
|
||||
# underscores. Max 32 chars. Slack app manifest accepts up to 50 slash
|
||||
# commands per app.
|
||||
_SLACK_MAX_SLASH_COMMANDS = 50
|
||||
_SLACK_NAME_LIMIT = 32
|
||||
_SLACK_INVALID_CHARS = re.compile(r"[^a-z0-9_\-]")
|
||||
|
||||
|
||||
def _sanitize_slack_name(raw: str) -> str:
|
||||
"""Convert a command name to a valid Slack slash command name.
|
||||
|
||||
Slack allows lowercase a-z, digits, hyphens, and underscores. Max 32
|
||||
chars. Uppercase is lowercased; invalid chars are stripped.
|
||||
"""
|
||||
name = raw.lower()
|
||||
name = _SLACK_INVALID_CHARS.sub("", name)
|
||||
name = name.strip("-_")
|
||||
return name[:_SLACK_NAME_LIMIT]
|
||||
|
||||
|
||||
def slack_native_slashes() -> list[tuple[str, str, str]]:
|
||||
"""Return (slash_name, description, usage_hint) triples for Slack.
|
||||
|
||||
Every gateway-available command in ``COMMAND_REGISTRY`` is surfaced as
|
||||
a standalone Slack slash command (e.g. ``/btw``, ``/stop``, ``/model``),
|
||||
matching Discord's and Telegram's model where every command is a
|
||||
first-class slash and not a ``/hermes <verb>`` subcommand.
|
||||
|
||||
Both canonical names and aliases are included so users can type any
|
||||
documented form (e.g. ``/background``, ``/bg``, and ``/btw`` all work).
|
||||
Plugin-registered slash commands are included too.
|
||||
|
||||
Results are clamped to Slack's 50-command limit with duplicate-name
|
||||
avoidance. ``/hermes`` is always reserved as the first entry so the
|
||||
legacy ``/hermes <subcommand>`` form keeps working for anything that
|
||||
gets dropped by the clamp or for free-form questions.
|
||||
"""
|
||||
overrides = _resolve_config_gates()
|
||||
entries: list[tuple[str, str, str]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
# Reserve /hermes as the catch-all top-level command.
|
||||
entries.append(("hermes", "Talk to Hermes or run a subcommand", "[subcommand] [args]"))
|
||||
seen.add("hermes")
|
||||
|
||||
def _add(name: str, desc: str, hint: str) -> None:
|
||||
slack_name = _sanitize_slack_name(name)
|
||||
if not slack_name or slack_name in seen:
|
||||
return
|
||||
if len(entries) >= _SLACK_MAX_SLASH_COMMANDS:
|
||||
return
|
||||
# Slack description cap is 2000 chars; keep it short.
|
||||
entries.append((slack_name, desc[:140], hint[:100]))
|
||||
seen.add(slack_name)
|
||||
|
||||
# First pass: canonical names (so they win slots if we hit the cap).
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if not _is_gateway_available(cmd, overrides):
|
||||
continue
|
||||
_add(cmd.name, cmd.description, cmd.args_hint or "")
|
||||
|
||||
# Second pass: aliases.
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if not _is_gateway_available(cmd, overrides):
|
||||
continue
|
||||
for alias in cmd.aliases:
|
||||
# Skip aliases that only differ from canonical by case/punctuation
|
||||
# normalization (already covered by _add dedup).
|
||||
_add(alias, f"Alias for /{cmd.name} — {cmd.description}", cmd.args_hint or "")
|
||||
|
||||
# Third pass: plugin commands.
|
||||
for name, description, args_hint in _iter_plugin_command_entries():
|
||||
_add(name, description, args_hint or "")
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def slack_app_manifest(request_url: str = "https://hermes-agent.local/slack/commands") -> dict[str, Any]:
|
||||
"""Generate a Slack app manifest with all gateway commands as slashes.
|
||||
|
||||
``request_url`` is required by Slack's manifest schema for every slash
|
||||
command, but in Socket Mode (which we use) Slack ignores it and routes
|
||||
the command event through the WebSocket. A placeholder URL is fine.
|
||||
|
||||
The returned dict is the ``features.slash_commands`` portion only —
|
||||
callers compose it into a full manifest (or merge into an existing
|
||||
one). Keeping it narrow avoids coupling us to the rest of the manifest
|
||||
schema (display_information, oauth_config, settings, etc.) which users
|
||||
set up once in the Slack UI and rarely change.
|
||||
"""
|
||||
slashes = []
|
||||
for name, desc, usage in slack_native_slashes():
|
||||
entry = {
|
||||
"command": f"/{name}",
|
||||
"description": desc or f"Run /{name}",
|
||||
"should_escape": False,
|
||||
"url": request_url,
|
||||
}
|
||||
if usage:
|
||||
entry["usage_hint"] = usage
|
||||
slashes.append(entry)
|
||||
return {"features": {"slash_commands": slashes}}
|
||||
|
||||
|
||||
def slack_subcommand_map() -> dict[str, str]:
|
||||
"""Return subcommand -> /command mapping for Slack /hermes handler.
|
||||
|
||||
|
||||
@@ -465,6 +465,7 @@ DEFAULT_CONFIG = {
|
||||
"command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)
|
||||
"record_sessions": False, # Auto-record browser sessions as WebM videos
|
||||
"allow_private_urls": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)
|
||||
"auto_local_for_private_urls": True, # When a cloud provider is set, auto-spawn local Chromium for LAN/localhost URLs instead of sending them to the cloud
|
||||
"cdp_url": "", # Optional persistent CDP endpoint for attaching to an existing Chromium/Chrome
|
||||
# CDP supervisor — dialog + frame detection via a persistent WebSocket.
|
||||
# Active only when a CDP-capable backend is attached (Browserbase or
|
||||
|
||||
@@ -4780,6 +4780,37 @@ def cmd_webhook(args):
|
||||
webhook_command(args)
|
||||
|
||||
|
||||
def cmd_slack(args):
|
||||
"""Slack integration helpers.
|
||||
|
||||
Dispatches ``hermes slack <subcommand>``. Currently supports:
|
||||
manifest — print or write a Slack app manifest with every gateway
|
||||
command registered as a first-class slash.
|
||||
"""
|
||||
sub = getattr(args, "slack_command", None)
|
||||
if sub in (None, ""):
|
||||
# No subcommand — print usage hint.
|
||||
print(
|
||||
"usage: hermes slack <subcommand>\n"
|
||||
"\n"
|
||||
"subcommands:\n"
|
||||
" manifest Generate a Slack app manifest with every gateway\n"
|
||||
" command registered as a native slash\n"
|
||||
"\n"
|
||||
"Run `hermes slack manifest -h` for details.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if sub == "manifest":
|
||||
from hermes_cli.slack_cli import slack_manifest_command
|
||||
|
||||
return slack_manifest_command(args)
|
||||
|
||||
print(f"Unknown slack subcommand: {sub}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_hooks(args):
|
||||
"""Shell-hook inspection and management."""
|
||||
from hermes_cli.hooks import hooks_command
|
||||
@@ -5925,6 +5956,88 @@ def _cmd_update_check():
|
||||
print(f" Run '{recommended_update_command()}' to install.")
|
||||
|
||||
|
||||
def _ensure_fhs_path_guard() -> None:
|
||||
"""Ensure /usr/local/bin is on PATH for RHEL-family root non-login shells.
|
||||
|
||||
Mirrors the post-symlink probe added to ``scripts/install.sh`` so that
|
||||
existing FHS-layout root installs on RHEL/CentOS/Rocky/Alma 8+ get
|
||||
repaired on ``hermes update`` without requiring a reinstall. The
|
||||
installer's assumption that ``/usr/local/bin`` is on PATH for every
|
||||
standard shell breaks on those distros in non-login interactive shells
|
||||
(su, sudo -s, tmux panes, some web terminals): /etc/bashrc doesn't
|
||||
add /usr/local/bin and /root/.bash_profile doesn't either. Symptom:
|
||||
``hermes`` prints ``command not found`` even though the symlink lives
|
||||
at /usr/local/bin/hermes.
|
||||
|
||||
Silent no-op on: non-Linux, non-root, non-FHS installs, and any system
|
||||
where ``bash -i -c 'command -v hermes'`` already resolves. Idempotent.
|
||||
"""
|
||||
if sys.platform != "linux":
|
||||
return
|
||||
try:
|
||||
if os.geteuid() != 0:
|
||||
return
|
||||
except AttributeError:
|
||||
return
|
||||
# Only act when this is actually an FHS-layout install (command link at
|
||||
# /usr/local/bin/hermes, code at /usr/local/lib/hermes-agent).
|
||||
fhs_link = Path("/usr/local/bin/hermes")
|
||||
if not fhs_link.is_symlink() and not fhs_link.exists():
|
||||
return
|
||||
|
||||
# Probe a fresh non-login interactive bash the way the user will use it.
|
||||
# ``bash -i -c`` sources ~/.bashrc but NOT ~/.bash_profile or /etc/profile,
|
||||
# which is the exact scenario where RHEL root loses /usr/local/bin.
|
||||
home = os.environ.get("HOME") or "/root"
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
["env", "-i",
|
||||
f"HOME={home}",
|
||||
f"TERM={os.environ.get('TERM', 'dumb')}",
|
||||
"bash", "-i", "-c", "command -v hermes"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return # no bash or probe hung — don't block update on this
|
||||
if probe.returncode == 0:
|
||||
return # already on PATH, nothing to do
|
||||
|
||||
path_line = 'export PATH="/usr/local/bin:$PATH"'
|
||||
path_comment = (
|
||||
"# Hermes Agent — ensure /usr/local/bin is on PATH "
|
||||
"(RHEL non-login shells)"
|
||||
)
|
||||
wrote_any = False
|
||||
for candidate in (".bashrc", ".bash_profile"):
|
||||
cfg = Path(home) / candidate
|
||||
if not cfg.is_file():
|
||||
continue
|
||||
try:
|
||||
existing = cfg.read_text(errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
# Idempotency: skip if any uncommented PATH= line already references
|
||||
# /usr/local/bin. Mirrors the grep pattern used by install.sh.
|
||||
already_guarded = any(
|
||||
"/usr/local/bin" in line
|
||||
and "PATH" in line
|
||||
and not line.lstrip().startswith("#")
|
||||
for line in existing.splitlines()
|
||||
)
|
||||
if already_guarded:
|
||||
continue
|
||||
try:
|
||||
with cfg.open("a", encoding="utf-8") as f:
|
||||
f.write("\n" + path_comment + "\n" + path_line + "\n")
|
||||
except OSError as e:
|
||||
print(f" ⚠ Could not update {cfg}: {e}")
|
||||
continue
|
||||
print(f" ✓ Added /usr/local/bin to PATH in {cfg}")
|
||||
wrote_any = True
|
||||
if wrote_any:
|
||||
print(" (reload your shell or run 'source ~/.bashrc' to pick it up)")
|
||||
|
||||
|
||||
def cmd_update(args):
|
||||
"""Update Hermes Agent to the latest version.
|
||||
|
||||
@@ -6368,6 +6481,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
print()
|
||||
print("✓ Update complete!")
|
||||
|
||||
# Repair RHEL-family root installs where /usr/local/bin isn't on PATH
|
||||
# for non-login interactive shells. No-op on every other platform.
|
||||
try:
|
||||
_ensure_fhs_path_guard()
|
||||
except Exception as e:
|
||||
logger.debug("FHS PATH guard check failed: %s", e)
|
||||
|
||||
# Write exit code *before* the gateway restart attempt.
|
||||
# When running as ``hermes update --gateway`` (spawned by the gateway's
|
||||
# /update command), this process lives inside the gateway's systemd
|
||||
@@ -7798,6 +7918,54 @@ For more help on a command:
|
||||
)
|
||||
whatsapp_parser.set_defaults(func=cmd_whatsapp)
|
||||
|
||||
# =========================================================================
|
||||
# slack command
|
||||
# =========================================================================
|
||||
slack_parser = subparsers.add_parser(
|
||||
"slack",
|
||||
help="Slack integration helpers (manifest generation, etc.)",
|
||||
description="Slack integration helpers for Hermes.",
|
||||
)
|
||||
slack_sub = slack_parser.add_subparsers(dest="slack_command")
|
||||
slack_manifest = slack_sub.add_parser(
|
||||
"manifest",
|
||||
help="Print or write a Slack app manifest with every gateway command "
|
||||
"registered as a native slash (/btw, /stop, /model, ...)",
|
||||
description=(
|
||||
"Generate a Slack app manifest that registers every gateway "
|
||||
"command in COMMAND_REGISTRY as a first-class Slack slash "
|
||||
"command (matching Discord and Telegram parity). Paste the "
|
||||
"output into Slack app config → Features → App Manifest → "
|
||||
"Edit, then Save. Reinstall the app if Slack prompts for it."
|
||||
),
|
||||
)
|
||||
slack_manifest.add_argument(
|
||||
"--write",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
metavar="PATH",
|
||||
help="Write manifest to a file instead of stdout. With no PATH "
|
||||
"writes to $HERMES_HOME/slack-manifest.json.",
|
||||
)
|
||||
slack_manifest.add_argument(
|
||||
"--name",
|
||||
default=None,
|
||||
help='Bot display name (default: "Hermes")',
|
||||
)
|
||||
slack_manifest.add_argument(
|
||||
"--description",
|
||||
default=None,
|
||||
help="Bot description shown in Slack's app directory.",
|
||||
)
|
||||
slack_manifest.add_argument(
|
||||
"--slashes-only",
|
||||
action="store_true",
|
||||
help="Emit only the features.slash_commands array (for merging "
|
||||
"into an existing manifest manually).",
|
||||
)
|
||||
slack_parser.set_defaults(func=cmd_slack)
|
||||
|
||||
# =========================================================================
|
||||
# login command
|
||||
# =========================================================================
|
||||
@@ -8453,6 +8621,12 @@ Examples:
|
||||
skills_list.add_argument(
|
||||
"--source", default="all", choices=["all", "hub", "builtin", "local"]
|
||||
)
|
||||
skills_list.add_argument(
|
||||
"--enabled-only",
|
||||
action="store_true",
|
||||
help="Hide disabled skills. Use with -p <profile> to see exactly "
|
||||
"which skills will load for that profile.",
|
||||
)
|
||||
|
||||
skills_check = skills_subparsers.add_parser(
|
||||
"check", help="Check installed hub skills for updates"
|
||||
|
||||
@@ -33,8 +33,6 @@ COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
|
||||
# (model_id, display description shown in menus)
|
||||
OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("moonshotai/kimi-k2.6", "recommended"),
|
||||
("deepseek/deepseek-v4-pro", ""),
|
||||
("deepseek/deepseek-v4-flash", ""),
|
||||
("anthropic/claude-opus-4.7", ""),
|
||||
("anthropic/claude-opus-4.6", ""),
|
||||
("anthropic/claude-sonnet-4.6", ""),
|
||||
@@ -111,8 +109,6 @@ def _codex_curated_models() -> list[str]:
|
||||
_PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"nous": [
|
||||
"moonshotai/kimi-k2.6",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"xiaomi/mimo-v2.5-pro",
|
||||
"xiaomi/mimo-v2.5",
|
||||
"anthropic/claude-opus-4.7",
|
||||
|
||||
+62
-14
@@ -1856,27 +1856,32 @@ def _setup_slack():
|
||||
if existing:
|
||||
print_info("Slack: already configured")
|
||||
if not prompt_yes_no("Reconfigure Slack?", False):
|
||||
# Even without reconfiguring, offer to refresh the manifest so
|
||||
# new commands (e.g. /btw, /stop, ...) get registered in Slack.
|
||||
if prompt_yes_no(
|
||||
"Regenerate the Slack app manifest with the latest command "
|
||||
"list? (recommended after `hermes update`)",
|
||||
True,
|
||||
):
|
||||
_write_slack_manifest_and_instruct()
|
||||
return
|
||||
|
||||
print_info("Steps to create a Slack app:")
|
||||
print_info(" 1. Go to https://api.slack.com/apps → Create New App (from scratch)")
|
||||
print_info(" 1. Go to https://api.slack.com/apps → Create New App")
|
||||
print_info(" Pick 'From an app manifest' — we'll generate one for you below.")
|
||||
print_info(" 2. Enable Socket Mode: Settings → Socket Mode → Enable")
|
||||
print_info(" • Create an App-Level Token with 'connections:write' scope")
|
||||
print_info(" 3. Add Bot Token Scopes: Features → OAuth & Permissions")
|
||||
print_info(" Required scopes: chat:write, app_mentions:read,")
|
||||
print_info(" channels:history, channels:read, im:history,")
|
||||
print_info(" im:read, im:write, users:read, files:read, files:write")
|
||||
print_info(" Optional for private channels: groups:history")
|
||||
print_info(" 4. Subscribe to Events: Features → Event Subscriptions → Enable")
|
||||
print_info(" Required events: message.im, message.channels, app_mention")
|
||||
print_info(" Optional for private channels: message.groups")
|
||||
print_warning(" ⚠ Without message.channels the bot will ONLY work in DMs,")
|
||||
print_warning(" not public channels.")
|
||||
print_info(" 5. Install to Workspace: Settings → Install App")
|
||||
print_info(" 6. Reinstall the app after any scope or event changes")
|
||||
print_info(" 7. After installing, invite the bot to channels: /invite @YourBot")
|
||||
print_info(" 3. Install to Workspace: Settings → Install App")
|
||||
print_info(" 4. After installing, invite the bot to channels: /invite @YourBot")
|
||||
print()
|
||||
print_info(" Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack/")
|
||||
print()
|
||||
|
||||
# Generate and write manifest up-front so the user can paste it into
|
||||
# the "Create from manifest" flow instead of clicking through scopes /
|
||||
# events / slash commands one at a time.
|
||||
_write_slack_manifest_and_instruct()
|
||||
|
||||
print()
|
||||
bot_token = prompt("Slack Bot Token (xoxb-...)", password=True)
|
||||
if not bot_token:
|
||||
@@ -1902,6 +1907,49 @@ def _setup_slack():
|
||||
print_info(" Set SLACK_ALLOW_ALL_USERS=true or GATEWAY_ALLOW_ALL_USERS=true only if you intentionally want open workspace access.")
|
||||
|
||||
|
||||
def _write_slack_manifest_and_instruct():
|
||||
"""Generate the Slack manifest, write it under HERMES_HOME, and print
|
||||
paste-into-Slack instructions.
|
||||
|
||||
Exposed as its own helper so both the initial setup flow and the
|
||||
"reconfigure? → no" branch can refresh the manifest without the user
|
||||
re-entering tokens. Failures are non-fatal — if the manifest write
|
||||
fails for any reason, we print a warning and skip rather than abort
|
||||
the whole Slack setup.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.slack_cli import _build_full_manifest
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
manifest = _build_full_manifest(
|
||||
bot_name="Hermes",
|
||||
bot_description="Your Hermes agent on Slack",
|
||||
)
|
||||
target = Path(get_hermes_home()) / "slack-manifest.json"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
import json as _json
|
||||
target.write_text(
|
||||
_json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print_success(f"Slack app manifest written to: {target}")
|
||||
print_info(
|
||||
" Paste it into https://api.slack.com/apps → your app → Features "
|
||||
"→ App Manifest → Edit, then Save. Slack will prompt to "
|
||||
"reinstall if scopes or slash commands changed."
|
||||
)
|
||||
print_info(
|
||||
" Re-run `hermes slack manifest --write` anytime to refresh after "
|
||||
"Hermes adds new commands."
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - best-effort UX helper
|
||||
print_warning(f"Couldn't write Slack manifest: {exc}")
|
||||
print_info(
|
||||
" You can generate it manually later with: "
|
||||
"hermes slack manifest --write"
|
||||
)
|
||||
|
||||
|
||||
def _setup_matrix():
|
||||
"""Configure Matrix credentials."""
|
||||
print_header("Matrix")
|
||||
|
||||
+60
-14
@@ -599,11 +599,24 @@ def inspect_skill(identifier: str) -> Optional[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def do_list(source_filter: str = "all", console: Optional[Console] = None) -> None:
|
||||
"""List installed skills, distinguishing hub, builtin, and local skills."""
|
||||
def do_list(source_filter: str = "all",
|
||||
enabled_only: bool = False,
|
||||
console: Optional[Console] = None) -> None:
|
||||
"""List installed skills, distinguishing hub, builtin, and local skills.
|
||||
|
||||
Args:
|
||||
source_filter: ``all`` | ``hub`` | ``builtin`` | ``local``.
|
||||
enabled_only: If True, hide disabled skills from the output.
|
||||
|
||||
Enabled/disabled state is resolved against the currently active profile's
|
||||
config — ``hermes -p <profile> skills list`` reads that profile's
|
||||
``skills.disabled`` list because ``-p`` swaps ``HERMES_HOME`` at process
|
||||
start. No explicit profile flag needed here.
|
||||
"""
|
||||
from tools.skills_hub import HubLockFile, ensure_hub_dirs
|
||||
from tools.skills_sync import _read_manifest
|
||||
from tools.skills_tool import _find_all_skills
|
||||
from agent.skill_utils import get_disabled_skill_names
|
||||
|
||||
c = console or _console
|
||||
ensure_hub_dirs()
|
||||
@@ -611,17 +624,26 @@ def do_list(source_filter: str = "all", console: Optional[Console] = None) -> No
|
||||
hub_installed = {e["name"]: e for e in lock.list_installed()}
|
||||
builtin_names = set(_read_manifest())
|
||||
|
||||
all_skills = _find_all_skills()
|
||||
# Pull ALL skills (including disabled ones) so we can annotate status.
|
||||
all_skills = _find_all_skills(skip_disabled=True)
|
||||
disabled_names = get_disabled_skill_names()
|
||||
|
||||
table = Table(title="Installed Skills")
|
||||
title = "Installed Skills"
|
||||
if enabled_only:
|
||||
title += " (enabled only)"
|
||||
|
||||
table = Table(title=title)
|
||||
table.add_column("Name", style="bold cyan")
|
||||
table.add_column("Category", style="dim")
|
||||
table.add_column("Source", style="dim")
|
||||
table.add_column("Trust", style="dim")
|
||||
table.add_column("Status", style="dim")
|
||||
|
||||
hub_count = 0
|
||||
builtin_count = 0
|
||||
local_count = 0
|
||||
enabled_count = 0
|
||||
disabled_count = 0
|
||||
|
||||
for skill in sorted(all_skills, key=lambda s: (s.get("category") or "", s["name"])):
|
||||
name = skill["name"]
|
||||
@@ -632,29 +654,48 @@ def do_list(source_filter: str = "all", console: Optional[Console] = None) -> No
|
||||
source_type = "hub"
|
||||
source_display = hub_entry.get("source", "hub")
|
||||
trust = hub_entry.get("trust_level", "community")
|
||||
hub_count += 1
|
||||
elif name in builtin_names:
|
||||
source_type = "builtin"
|
||||
source_display = "builtin"
|
||||
trust = "builtin"
|
||||
builtin_count += 1
|
||||
else:
|
||||
source_type = "local"
|
||||
source_display = "local"
|
||||
trust = "local"
|
||||
local_count += 1
|
||||
|
||||
if source_filter != "all" and source_filter != source_type:
|
||||
continue
|
||||
|
||||
is_enabled = name not in disabled_names
|
||||
if enabled_only and not is_enabled:
|
||||
continue
|
||||
|
||||
if source_type == "hub":
|
||||
hub_count += 1
|
||||
elif source_type == "builtin":
|
||||
builtin_count += 1
|
||||
else:
|
||||
local_count += 1
|
||||
|
||||
if is_enabled:
|
||||
enabled_count += 1
|
||||
status_cell = "[bold green]enabled[/]"
|
||||
else:
|
||||
disabled_count += 1
|
||||
status_cell = "[dim red]disabled[/]"
|
||||
|
||||
trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow", "local": "dim"}.get(trust, "dim")
|
||||
trust_label = "official" if source_display == "official" else trust
|
||||
table.add_row(name, category, source_display, f"[{trust_style}]{trust_label}[/]")
|
||||
table.add_row(name, category, source_display, f"[{trust_style}]{trust_label}[/]", status_cell)
|
||||
|
||||
c.print(table)
|
||||
c.print(
|
||||
f"[dim]{hub_count} hub-installed, {builtin_count} builtin, {local_count} local[/]\n"
|
||||
)
|
||||
summary = f"[dim]{hub_count} hub-installed, {builtin_count} builtin, {local_count} local"
|
||||
if enabled_only:
|
||||
summary += f" — {enabled_count} enabled shown"
|
||||
else:
|
||||
summary += f" — {enabled_count} enabled, {disabled_count} disabled"
|
||||
summary += "[/]\n"
|
||||
c.print(summary)
|
||||
|
||||
|
||||
def do_check(name: Optional[str] = None, console: Optional[Console] = None) -> None:
|
||||
@@ -1127,7 +1168,10 @@ def skills_command(args) -> None:
|
||||
elif action == "inspect":
|
||||
do_inspect(args.identifier)
|
||||
elif action == "list":
|
||||
do_list(source_filter=args.source)
|
||||
do_list(
|
||||
source_filter=args.source,
|
||||
enabled_only=getattr(args, "enabled_only", False),
|
||||
)
|
||||
elif action == "check":
|
||||
do_check(name=getattr(args, "name", None))
|
||||
elif action == "update":
|
||||
@@ -1279,11 +1323,12 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
||||
|
||||
elif action == "list":
|
||||
source_filter = "all"
|
||||
enabled_only = "--enabled-only" in args or "--enabled" in args
|
||||
if "--source" in args:
|
||||
idx = args.index("--source")
|
||||
if idx + 1 < len(args):
|
||||
source_filter = args[idx + 1]
|
||||
do_list(source_filter=source_filter, console=c)
|
||||
do_list(source_filter=source_filter, enabled_only=enabled_only, console=c)
|
||||
|
||||
elif action == "check":
|
||||
name = args[0] if args else None
|
||||
@@ -1371,7 +1416,8 @@ def _print_skills_help(console: Console) -> None:
|
||||
" [cyan]search[/] <query> Search registries for skills\n"
|
||||
" [cyan]install[/] <identifier> Install a skill (with security scan)\n"
|
||||
" [cyan]inspect[/] <identifier> Preview a skill without installing\n"
|
||||
" [cyan]list[/] [--source hub|builtin|local] List installed skills\n"
|
||||
" [cyan]list[/] [--source hub|builtin|local] [--enabled-only]\n"
|
||||
" List installed skills; --enabled-only filters to the active profile's live set\n"
|
||||
" [cyan]check[/] [name] Check hub skills for upstream updates\n"
|
||||
" [cyan]update[/] [name] Update hub skills with upstream changes\n"
|
||||
" [cyan]audit[/] [name] Re-scan hub skills for security\n"
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""``hermes slack ...`` CLI subcommands.
|
||||
|
||||
Today only ``hermes slack manifest`` is implemented — it generates the
|
||||
Slack app manifest JSON for registering every gateway command as a native
|
||||
Slack slash (``/btw``, ``/stop``, ``/model``, …) so users get the same
|
||||
first-class slash UX Discord and Telegram already have.
|
||||
|
||||
Typical workflow::
|
||||
|
||||
$ hermes slack manifest > slack-manifest.json
|
||||
# or:
|
||||
$ hermes slack manifest --write
|
||||
|
||||
Then paste the printed JSON into the Slack app config (Features → App
|
||||
Manifest → Edit) and click Save. Slack diffs the manifest and prompts
|
||||
for reinstall when scopes/commands change.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _build_full_manifest(bot_name: str, bot_description: str) -> dict:
|
||||
"""Build a full Slack manifest merging display info + our slash list.
|
||||
|
||||
The slash-command list is always generated from ``COMMAND_REGISTRY`` so
|
||||
it stays in sync with the rest of Hermes. Other manifest sections
|
||||
(display info, OAuth scopes, socket mode) are set to sensible defaults
|
||||
for a Hermes deployment — users can tweak them in the Slack UI after
|
||||
pasting.
|
||||
"""
|
||||
from hermes_cli.commands import slack_app_manifest
|
||||
|
||||
partial = slack_app_manifest()
|
||||
slashes = partial["features"]["slash_commands"]
|
||||
|
||||
return {
|
||||
"_metadata": {
|
||||
"major_version": 1,
|
||||
"minor_version": 1,
|
||||
},
|
||||
"display_information": {
|
||||
"name": bot_name[:35],
|
||||
"description": (bot_description or "Your Hermes agent on Slack")[:140],
|
||||
"background_color": "#1a1a2e",
|
||||
},
|
||||
"features": {
|
||||
"bot_user": {
|
||||
"display_name": bot_name[:80],
|
||||
"always_online": True,
|
||||
},
|
||||
"slash_commands": slashes,
|
||||
"assistant_view": {
|
||||
"assistant_description": "Chat with Hermes in threads and DMs.",
|
||||
},
|
||||
},
|
||||
"oauth_config": {
|
||||
"scopes": {
|
||||
"bot": [
|
||||
"app_mentions:read",
|
||||
"assistant:write",
|
||||
"channels:history",
|
||||
"channels:read",
|
||||
"chat:write",
|
||||
"commands",
|
||||
"files:read",
|
||||
"files:write",
|
||||
"groups:history",
|
||||
"im:history",
|
||||
"im:read",
|
||||
"im:write",
|
||||
"users:read",
|
||||
],
|
||||
},
|
||||
},
|
||||
"settings": {
|
||||
"event_subscriptions": {
|
||||
"bot_events": [
|
||||
"app_mention",
|
||||
"assistant_thread_context_changed",
|
||||
"assistant_thread_started",
|
||||
"message.channels",
|
||||
"message.groups",
|
||||
"message.im",
|
||||
],
|
||||
},
|
||||
"interactivity": {
|
||||
"is_enabled": True,
|
||||
},
|
||||
"org_deploy_enabled": False,
|
||||
"socket_mode_enabled": True,
|
||||
"token_rotation_enabled": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def slack_manifest_command(args) -> int:
|
||||
"""Print or write a Slack app manifest JSON.
|
||||
|
||||
Flags (all parsed in ``hermes_cli/main.py``):
|
||||
--write [PATH] Write to file instead of stdout (default path:
|
||||
``$HERMES_HOME/slack-manifest.json``)
|
||||
--name NAME Override the bot display name (default: "Hermes")
|
||||
--description DESC Override the bot description
|
||||
--slashes-only Emit only the ``features.slash_commands`` array (for
|
||||
merging into an existing manifest manually)
|
||||
"""
|
||||
name = getattr(args, "name", None) or "Hermes"
|
||||
description = getattr(args, "description", None) or "Your Hermes agent on Slack"
|
||||
|
||||
if getattr(args, "slashes_only", False):
|
||||
from hermes_cli.commands import slack_app_manifest
|
||||
|
||||
manifest = slack_app_manifest()["features"]["slash_commands"]
|
||||
else:
|
||||
manifest = _build_full_manifest(name, description)
|
||||
|
||||
payload = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
write_target = getattr(args, "write", None)
|
||||
if write_target is not None:
|
||||
if isinstance(write_target, bool) and write_target:
|
||||
# --write with no value → default location
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
target = Path(get_hermes_home()) / "slack-manifest.json"
|
||||
except Exception:
|
||||
target = Path.home() / ".hermes" / "slack-manifest.json"
|
||||
else:
|
||||
target = Path(write_target).expanduser()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(payload, encoding="utf-8")
|
||||
print(f"Slack manifest written to: {target}", file=sys.stderr)
|
||||
print(
|
||||
"\nNext steps:\n"
|
||||
" 1. Open https://api.slack.com/apps and pick your Hermes app\n"
|
||||
" (or create a new one: Create New App → From an app manifest).\n"
|
||||
f" 2. Features → App Manifest → paste the contents of\n"
|
||||
f" {target}\n"
|
||||
" 3. Save; Slack will prompt to reinstall the app if scopes or\n"
|
||||
" slash commands changed.\n"
|
||||
" 4. Make sure Socket Mode is enabled and you have a bot token\n"
|
||||
" (xoxb-...) and app token (xapp-...) configured via\n"
|
||||
" `hermes setup`.\n",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
sys.stdout.write(payload)
|
||||
return 0
|
||||
+12
-1
@@ -832,7 +832,18 @@ class SessionDB:
|
||||
params = []
|
||||
|
||||
if not include_children:
|
||||
where_clauses.append("s.parent_session_id IS NULL")
|
||||
# Show root sessions and branch sessions (whose parent ended with
|
||||
# end_reason='branched' before the child was created), while still
|
||||
# hiding sub-agent runs and compression continuations (which also
|
||||
# carry a parent_session_id but were spawned while the parent was
|
||||
# still live — i.e., started_at < parent.ended_at).
|
||||
where_clauses.append(
|
||||
"(s.parent_session_id IS NULL"
|
||||
" OR EXISTS (SELECT 1 FROM sessions p"
|
||||
" WHERE p.id = s.parent_session_id"
|
||||
" AND p.end_reason = 'branched'"
|
||||
" AND s.started_at >= p.ended_at))"
|
||||
)
|
||||
|
||||
if source:
|
||||
where_clauses.append("s.source = ?")
|
||||
|
||||
+12
-3
@@ -3304,10 +3304,19 @@ class AIAgent:
|
||||
logger.warning("Background memory/skill review failed: %s", e)
|
||||
self._emit_auxiliary_failure("background review", e)
|
||||
finally:
|
||||
# Close all resources (httpx client, subprocesses, etc.) so
|
||||
# GC doesn't try to clean them up on a dead asyncio event
|
||||
# loop (which produces "Event loop is closed" errors).
|
||||
# Background review agents can initialize memory providers
|
||||
# (for example Hindsight) that own their own network clients.
|
||||
# Explicitly stop those providers before closing the agent so
|
||||
# their aiohttp sessions do not leak until GC/process exit.
|
||||
# Then close all remaining resources (httpx client,
|
||||
# subprocesses, etc.) so GC doesn't try to clean them up on a
|
||||
# dead asyncio event loop (which produces "Event loop is
|
||||
# closed" errors).
|
||||
if review_agent is not None:
|
||||
try:
|
||||
review_agent.shutdown_memory_provider()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
review_agent.close()
|
||||
except Exception:
|
||||
|
||||
+29
-2
@@ -1055,10 +1055,37 @@ setup_path() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# FHS layout: /usr/local/bin is on PATH for every standard shell, nothing to inject.
|
||||
# FHS layout: /usr/local/bin is normally on PATH for login shells (via
|
||||
# /etc/profile pathmunge), but on RHEL/CentOS/Rocky/Alma 8+ non-login
|
||||
# interactive root shells (su, sudo -s, tmux panes, some web terminals)
|
||||
# only source /etc/bashrc, which does NOT add /usr/local/bin — and
|
||||
# /root/.bash_profile doesn't either. So verify with `command -v` and
|
||||
# fall back to writing a PATH guard into /root/.bashrc when needed.
|
||||
if [ "$ROOT_FHS_LAYOUT" = true ]; then
|
||||
export PATH="$command_link_dir:$PATH"
|
||||
log_info "/usr/local/bin is already on PATH for all shells"
|
||||
# Probe a fresh non-login interactive bash the way the user will use it.
|
||||
# `bash -i -c` sources ~/.bashrc but NOT ~/.bash_profile or /etc/profile,
|
||||
# which is the exact scenario where RHEL root loses /usr/local/bin.
|
||||
if env -i HOME="$HOME" TERM="${TERM:-dumb}" bash -i -c 'command -v hermes' \
|
||||
>/dev/null 2>&1; then
|
||||
log_info "/usr/local/bin is already on PATH for all shells"
|
||||
log_success "hermes command ready"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "hermes not on PATH in non-login shells (common on RHEL-family)"
|
||||
PATH_LINE='export PATH="/usr/local/bin:$PATH"'
|
||||
PATH_COMMENT='# Hermes Agent — ensure /usr/local/bin is on PATH (RHEL non-login shells)'
|
||||
for SHELL_CONFIG in "$HOME/.bashrc" "$HOME/.bash_profile"; do
|
||||
[ -f "$SHELL_CONFIG" ] || continue
|
||||
if ! grep -v '^[[:space:]]*#' "$SHELL_CONFIG" 2>/dev/null \
|
||||
| grep -qE 'PATH=.*(/usr/local/bin|\$command_link_dir)'; then
|
||||
echo "" >> "$SHELL_CONFIG"
|
||||
echo "$PATH_COMMENT" >> "$SHELL_CONFIG"
|
||||
echo "$PATH_LINE" >> "$SHELL_CONFIG"
|
||||
log_success "Added /usr/local/bin to PATH in $SHELL_CONFIG"
|
||||
fi
|
||||
done
|
||||
log_success "hermes command ready"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -70,6 +70,8 @@ AUTHOR_MAP = {
|
||||
"keira.voss94@gmail.com": "keiravoss94",
|
||||
"16443023+stablegenius49@users.noreply.github.com": "stablegenius49",
|
||||
"fqsy1416@gmail.com": "EKKOLearnAI",
|
||||
"octo-patch@github.com": "octo-patch",
|
||||
"math0r-be@github.com": "math0r-be",
|
||||
"simbamax99@gmail.com": "simbam99",
|
||||
"iris@growthpillars.co": "irispillars",
|
||||
"185121704+stablegenius49@users.noreply.github.com": "stablegenius49",
|
||||
@@ -116,9 +118,15 @@ AUTHOR_MAP = {
|
||||
"Mibayy@users.noreply.github.com": "Mibayy",
|
||||
"mibayy@users.noreply.github.com": "Mibayy",
|
||||
"135070653+sgaofen@users.noreply.github.com": "sgaofen",
|
||||
"lzy.dev@gmail.com": "zhiyanliu",
|
||||
"me@janstepanovsky.cz": "hhhonzik",
|
||||
"139848623+hhuang91@users.noreply.github.com": "hhuang91",
|
||||
"s.ozaki@ebinou.net": "Satoshi-agi",
|
||||
"10774721+kunlabs@users.noreply.github.com": "kunlabs",
|
||||
"nocoo@users.noreply.github.com": "nocoo",
|
||||
"30841158+n-WN@users.noreply.github.com": "n-WN",
|
||||
"tsuijinglei@gmail.com": "hiddenpuppy",
|
||||
"buraysandro9@gmail.com": "ygd58",
|
||||
"jerome@clawwork.ai": "HiddenPuppy",
|
||||
"jerome.benoit@sap.com": "jerome-benoit",
|
||||
"wysie@users.noreply.github.com": "Wysie",
|
||||
@@ -191,6 +199,7 @@ AUTHOR_MAP = {
|
||||
"satelerd@gmail.com": "satelerd",
|
||||
"dan@danlynn.com": "danklynn",
|
||||
"mattmaximo@hotmail.com": "MattMaximo",
|
||||
"MatthewRHardwick@gmail.com": "mrhwick",
|
||||
"149063006+j3ffffff@users.noreply.github.com": "j3ffffff",
|
||||
"A-FdL-Prog@users.noreply.github.com": "A-FdL-Prog",
|
||||
"l0hde@users.noreply.github.com": "l0hde",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
description: Skills for monitoring, aggregating, and processing RSS feeds, blogs, and web content sources.
|
||||
---
|
||||
@@ -160,6 +160,30 @@ class TestBranchCommandCLI:
|
||||
assert agent.reset_session_state.called
|
||||
assert agent._last_flushed_db_idx == 4 # len(conversation_history)
|
||||
|
||||
def test_branch_updates_agent_session_log_file(self, cli_instance, session_db, tmp_path):
|
||||
"""Branching must redirect the agent's session_log_file to the new session's path."""
|
||||
from cli import HermesCLI
|
||||
from pathlib import Path
|
||||
|
||||
logs_dir = tmp_path / "sessions"
|
||||
logs_dir.mkdir()
|
||||
|
||||
agent = MagicMock()
|
||||
agent._last_flushed_db_idx = 0
|
||||
agent.logs_dir = logs_dir
|
||||
agent.session_log_file = logs_dir / f"session_{cli_instance.session_id}.json"
|
||||
cli_instance.agent = agent
|
||||
|
||||
old_log_file = agent.session_log_file
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
new_session_id = cli_instance.session_id
|
||||
expected_log = logs_dir / f"session_{new_session_id}.json"
|
||||
assert agent.session_log_file == expected_log, (
|
||||
"session_log_file must point to the branch session, not the original"
|
||||
)
|
||||
assert agent.session_log_file != old_log_file
|
||||
|
||||
def test_branch_sets_resumed_flag(self, cli_instance, session_db):
|
||||
"""Branch should set _resumed=True to prevent auto-title generation."""
|
||||
from cli import HermesCLI
|
||||
|
||||
@@ -31,6 +31,40 @@ def _make_cli_stub():
|
||||
return cli
|
||||
|
||||
|
||||
def _make_background_cli_stub():
|
||||
cli = _make_cli_stub()
|
||||
cli._background_task_counter = 0
|
||||
cli._background_tasks = {}
|
||||
cli._ensure_runtime_credentials = MagicMock(return_value=True)
|
||||
cli._resolve_turn_agent_config = MagicMock(return_value={
|
||||
"model": "test-model",
|
||||
"runtime": {
|
||||
"api_key": "test-key",
|
||||
"base_url": "https://example.test/v1",
|
||||
"provider": "test",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
"request_overrides": None,
|
||||
})
|
||||
cli.max_turns = 90
|
||||
cli.enabled_toolsets = []
|
||||
cli._session_db = None
|
||||
cli.reasoning_config = {}
|
||||
cli.service_tier = None
|
||||
cli._providers_only = None
|
||||
cli._providers_ignore = None
|
||||
cli._providers_order = None
|
||||
cli._provider_sort = None
|
||||
cli._provider_require_params = None
|
||||
cli._provider_data_collection = None
|
||||
cli._fallback_model = None
|
||||
cli._agent_running = False
|
||||
cli._spinner_text = ""
|
||||
cli.bell_on_complete = False
|
||||
cli.final_response_markdown = "strip"
|
||||
return cli
|
||||
|
||||
|
||||
class TestCliApprovalUi:
|
||||
def test_sudo_prompt_restores_existing_draft_after_response(self):
|
||||
cli = _make_cli_stub()
|
||||
@@ -255,6 +289,54 @@ class TestCliApprovalUi:
|
||||
# Command got truncated with a marker.
|
||||
assert "(command truncated" in rendered
|
||||
|
||||
def test_background_task_registers_thread_local_approval_callbacks(self):
|
||||
"""Background /btw tasks must use the prompt_toolkit approval UI.
|
||||
|
||||
The foreground chat path registers dangerous-command callbacks inside
|
||||
its worker thread because tools.terminal_tool stores them in
|
||||
threading.local(). /background used to skip that, so dangerous commands
|
||||
fell back to raw input() in a background thread and timed out under
|
||||
prompt_toolkit.
|
||||
"""
|
||||
cli = _make_background_cli_stub()
|
||||
seen = {}
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self, **kwargs):
|
||||
self._print_fn = None
|
||||
self.thinking_callback = None
|
||||
|
||||
def run_conversation(self, **kwargs):
|
||||
from tools.terminal_tool import (
|
||||
_get_approval_callback,
|
||||
_get_sudo_password_callback,
|
||||
)
|
||||
|
||||
seen["approval"] = _get_approval_callback()
|
||||
seen["sudo"] = _get_sudo_password_callback()
|
||||
return {
|
||||
"final_response": "done",
|
||||
"messages": [],
|
||||
"completed": True,
|
||||
"failed": False,
|
||||
}
|
||||
|
||||
with patch.object(cli_module, "AIAgent", FakeAgent), \
|
||||
patch.object(cli_module, "_cprint"), \
|
||||
patch.object(cli_module, "ChatConsole") as chat_console:
|
||||
chat_console.return_value.print = MagicMock()
|
||||
cli._handle_background_command("/btw check weather")
|
||||
|
||||
deadline = time.time() + 2
|
||||
while cli._background_tasks and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
|
||||
assert seen["approval"].__self__ is cli
|
||||
assert seen["approval"].__func__ is HermesCLI._approval_callback
|
||||
assert seen["sudo"].__self__ is cli
|
||||
assert seen["sudo"].__func__ is HermesCLI._sudo_password_callback
|
||||
assert not cli._background_tasks
|
||||
|
||||
|
||||
class TestApprovalCallbackThreadLocalWiring:
|
||||
"""Regression guard for the thread-local callback freeze (#13617 / #13618).
|
||||
|
||||
@@ -211,6 +211,21 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
|
||||
"SIGNAL_ALLOW_ALL_USERS",
|
||||
"EMAIL_ALLOW_ALL_USERS",
|
||||
"SMS_ALLOW_ALL_USERS",
|
||||
# Platform gating — set by load_gateway_config() as a side effect when
|
||||
# a config.yaml is present, so individual test bodies that call the
|
||||
# loader leak these values into later tests on the same xdist worker.
|
||||
# Force-clear on every test setup so the leak can't happen.
|
||||
"SLACK_REQUIRE_MENTION",
|
||||
"SLACK_STRICT_MENTION",
|
||||
"SLACK_FREE_RESPONSE_CHANNELS",
|
||||
"SLACK_ALLOW_BOTS",
|
||||
"SLACK_REACTIONS",
|
||||
"DISCORD_REQUIRE_MENTION",
|
||||
"DISCORD_FREE_RESPONSE_CHANNELS",
|
||||
"TELEGRAM_REQUIRE_MENTION",
|
||||
"WHATSAPP_REQUIRE_MENTION",
|
||||
"DINGTALK_REQUIRE_MENTION",
|
||||
"MATRIX_REQUIRE_MENTION",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -1043,3 +1043,132 @@ class TestAgentCacheIdleResume:
|
||||
new_agent.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_FAKE_NOW = 10_000.0 # Fixed epoch for deterministic time assertions
|
||||
|
||||
|
||||
class TestCachedAgentInactivityReset:
|
||||
"""Inactivity-clock reset must be gated on _interrupt_depth == 0.
|
||||
|
||||
On interrupt-recursive turns (_interrupt_depth > 0) the clock must
|
||||
keep accumulating so the inactivity watchdog can fire when a turn is
|
||||
stuck in an interrupt loop. Resetting unconditionally prevented the
|
||||
30-min timeout from triggering (#15654). The depth-0 reset is still
|
||||
needed: a session idle for 29 min must not trip the watchdog before
|
||||
the new turn makes its first API call (#9051).
|
||||
"""
|
||||
|
||||
def _fake_agent(self, stale_seconds: float = 1800.0):
|
||||
m = MagicMock()
|
||||
m._last_activity_ts = _FAKE_NOW - stale_seconds
|
||||
m._api_call_count = 10
|
||||
m._last_activity_desc = "previous turn activity"
|
||||
return m
|
||||
|
||||
def test_fresh_turn_resets_idle_clock(self):
|
||||
"""interrupt_depth=0: clock resets so a post-idle turn gets a
|
||||
fresh 30-min inactivity window (guard for #9051)."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
agent = self._fake_agent(stale_seconds=1800.0)
|
||||
old_ts = agent._last_activity_ts
|
||||
|
||||
with patch("gateway.run.time") as mock_time:
|
||||
mock_time.time.return_value = _FAKE_NOW
|
||||
GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=0)
|
||||
|
||||
assert agent._last_activity_ts == _FAKE_NOW, (
|
||||
"_last_activity_ts was not reset on a fresh turn (interrupt_depth=0)"
|
||||
)
|
||||
assert agent._last_activity_ts > old_ts, (
|
||||
"Stale idle time should be cleared so the new turn gets a fresh window"
|
||||
)
|
||||
|
||||
def test_fresh_turn_resets_desc(self):
|
||||
"""interrupt_depth=0: description is updated to reflect the new turn."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
agent = self._fake_agent()
|
||||
|
||||
with patch("gateway.run.time") as mock_time:
|
||||
mock_time.time.return_value = _FAKE_NOW
|
||||
GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=0)
|
||||
|
||||
assert agent._last_activity_desc == "starting new turn (cached)"
|
||||
|
||||
def test_interrupt_turn_preserves_idle_clock(self):
|
||||
"""interrupt_depth=1: clock preserved so accumulated stuck-turn
|
||||
idle time is not discarded by an interrupt-recursive re-entry (#15654)."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
agent = self._fake_agent(stale_seconds=1200.0)
|
||||
old_ts = agent._last_activity_ts
|
||||
|
||||
GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=1)
|
||||
|
||||
assert agent._last_activity_ts == old_ts, (
|
||||
"_last_activity_ts must not be reset on interrupt-recursive turns "
|
||||
"(interrupt_depth>0) — the watchdog needs the accumulated idle time"
|
||||
)
|
||||
|
||||
def test_interrupt_turn_preserves_desc(self):
|
||||
"""interrupt_depth=1: desc preserved — it is semantically paired with ts."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
agent = self._fake_agent(stale_seconds=1200.0)
|
||||
|
||||
GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=1)
|
||||
|
||||
assert agent._last_activity_desc == "previous turn activity", (
|
||||
"_last_activity_desc must not change on interrupt-recursive turns; "
|
||||
"it describes the activity *at* _last_activity_ts"
|
||||
)
|
||||
|
||||
def test_deep_interrupt_recursion_preserves_idle_clock(self):
|
||||
"""interrupt_depth=MAX-1: clock still preserved at any non-zero depth."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
agent = self._fake_agent(stale_seconds=600.0)
|
||||
old_ts = agent._last_activity_ts
|
||||
|
||||
GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=4)
|
||||
|
||||
assert agent._last_activity_ts == old_ts
|
||||
|
||||
def test_api_call_count_reset_regardless_of_depth(self):
|
||||
"""_api_call_count is always reset to 0 for the new turn, at any depth."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
agent_fresh = self._fake_agent()
|
||||
agent_interrupted = self._fake_agent()
|
||||
|
||||
with patch("gateway.run.time") as mock_time:
|
||||
mock_time.time.return_value = _FAKE_NOW
|
||||
GatewayRunner._init_cached_agent_for_turn(agent_fresh, interrupt_depth=0)
|
||||
GatewayRunner._init_cached_agent_for_turn(agent_interrupted, interrupt_depth=1)
|
||||
|
||||
assert agent_fresh._api_call_count == 0
|
||||
assert agent_interrupted._api_call_count == 0
|
||||
|
||||
def test_watchdog_accumulation_across_recursive_turns(self):
|
||||
"""Scenario: stuck turn + user interrupt → recursive turn.
|
||||
|
||||
The idle time seen by the watchdog must reflect the full stuck
|
||||
duration, not restart from zero on the recursive re-entry.
|
||||
"""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
STUCK_FOR = 1750.0
|
||||
agent = self._fake_agent(stale_seconds=STUCK_FOR)
|
||||
|
||||
# Simulate: user sees "Still working..." and sends another message.
|
||||
# That triggers an interrupt → _run_agent recurses at depth=1.
|
||||
GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=1)
|
||||
|
||||
# Watchdog sees time.time() - _last_activity_ts ≥ STUCK_FOR.
|
||||
idle_secs = _FAKE_NOW - agent._last_activity_ts
|
||||
assert idle_secs >= STUCK_FOR - 1.0, (
|
||||
f"Watchdog would see {idle_secs:.0f}s idle, expected ~{STUCK_FOR}s. "
|
||||
"Inactivity timeout could not fire for a stuck interrupted turn."
|
||||
)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Tests for gateway/channel_directory.py — channel resolution and display."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from gateway.channel_directory import (
|
||||
build_channel_directory,
|
||||
@@ -12,6 +14,7 @@ from gateway.channel_directory import (
|
||||
format_directory_for_display,
|
||||
load_directory,
|
||||
_build_from_sessions,
|
||||
_build_slack,
|
||||
DIRECTORY_PATH,
|
||||
)
|
||||
|
||||
@@ -62,7 +65,7 @@ class TestBuildChannelDirectoryWrites:
|
||||
monkeypatch.setattr(json, "dump", broken_dump)
|
||||
|
||||
with patch("gateway.channel_directory.DIRECTORY_PATH", cache_file):
|
||||
build_channel_directory({})
|
||||
asyncio.run(build_channel_directory({}))
|
||||
result = load_directory()
|
||||
|
||||
assert result == previous
|
||||
@@ -142,6 +145,21 @@ class TestResolveChannelName:
|
||||
with self._setup(tmp_path, platforms):
|
||||
assert resolve_channel_name("telegram", "Coaching Chat / topic 17585") == "-1001:17585"
|
||||
|
||||
def test_id_match_takes_precedence_over_name(self, tmp_path):
|
||||
"""A raw channel ID resolves to itself, even when a different
|
||||
channel happens to be named the same string. Case-sensitive: Slack
|
||||
IDs are uppercase and must not be normalized away."""
|
||||
platforms = {
|
||||
"slack": [
|
||||
{"id": "C0B0QV5434G", "name": "engineering", "type": "channel"},
|
||||
{"id": "C99", "name": "c0b0qv5434g", "type": "channel"},
|
||||
]
|
||||
}
|
||||
with self._setup(tmp_path, platforms):
|
||||
assert resolve_channel_name("slack", "C0B0QV5434G") == "C0B0QV5434G"
|
||||
# Lowercase still falls through to name matching (case-insensitive)
|
||||
assert resolve_channel_name("slack", "c0b0qv5434g") == "C99"
|
||||
|
||||
def test_display_label_with_type_suffix_resolves(self, tmp_path):
|
||||
platforms = {
|
||||
"telegram": [
|
||||
@@ -332,3 +350,135 @@ class TestLookupChannelType:
|
||||
}
|
||||
with self._setup(tmp_path, platforms):
|
||||
assert lookup_channel_type("discord", "300") is None
|
||||
|
||||
|
||||
def _make_slack_adapter(team_clients):
|
||||
"""Build a stand-in for SlackAdapter exposing only ``_team_clients``."""
|
||||
return SimpleNamespace(_team_clients=team_clients)
|
||||
|
||||
|
||||
def _make_slack_client(pages):
|
||||
"""Build an AsyncWebClient mock whose ``users_conversations`` returns pages."""
|
||||
client = MagicMock()
|
||||
client.users_conversations = AsyncMock(side_effect=pages)
|
||||
return client
|
||||
|
||||
|
||||
class TestBuildSlack:
|
||||
"""_build_slack actually calls users.conversations on each workspace client."""
|
||||
|
||||
def test_no_team_clients_falls_back_to_sessions(self, tmp_path):
|
||||
sessions_path = tmp_path / "sessions" / "sessions.json"
|
||||
sessions_path.parent.mkdir(parents=True)
|
||||
sessions_path.write_text(json.dumps({
|
||||
"s1": {"origin": {"platform": "slack", "chat_id": "D123", "chat_name": "Alice"}},
|
||||
}))
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
entries = asyncio.run(_build_slack(_make_slack_adapter({})))
|
||||
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["id"] == "D123"
|
||||
|
||||
def test_lists_channels_from_users_conversations(self, tmp_path):
|
||||
client = _make_slack_client([
|
||||
{
|
||||
"ok": True,
|
||||
"channels": [
|
||||
{"id": "C0B0QV5434G", "name": "engineering", "is_private": False},
|
||||
{"id": "G123ABCDEF", "name": "secret-chat", "is_private": True},
|
||||
],
|
||||
"response_metadata": {},
|
||||
},
|
||||
])
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
entries = asyncio.run(_build_slack(_make_slack_adapter({"T1": client})))
|
||||
|
||||
ids = {e["id"] for e in entries}
|
||||
assert ids == {"C0B0QV5434G", "G123ABCDEF"}
|
||||
types = {e["id"]: e["type"] for e in entries}
|
||||
assert types["C0B0QV5434G"] == "channel"
|
||||
assert types["G123ABCDEF"] == "private"
|
||||
client.users_conversations.assert_awaited_once()
|
||||
|
||||
def test_paginates_via_response_metadata_cursor(self, tmp_path):
|
||||
client = _make_slack_client([
|
||||
{
|
||||
"ok": True,
|
||||
"channels": [{"id": "C001", "name": "first", "is_private": False}],
|
||||
"response_metadata": {"next_cursor": "cur1"},
|
||||
},
|
||||
{
|
||||
"ok": True,
|
||||
"channels": [{"id": "C002", "name": "second", "is_private": False}],
|
||||
"response_metadata": {"next_cursor": ""},
|
||||
},
|
||||
])
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
entries = asyncio.run(_build_slack(_make_slack_adapter({"T1": client})))
|
||||
|
||||
assert {e["id"] for e in entries} == {"C001", "C002"}
|
||||
assert client.users_conversations.await_count == 2
|
||||
|
||||
def test_per_workspace_error_does_not_block_others(self, tmp_path):
|
||||
bad = MagicMock()
|
||||
bad.users_conversations = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
good = _make_slack_client([
|
||||
{
|
||||
"ok": True,
|
||||
"channels": [{"id": "C999", "name": "ok-channel", "is_private": False}],
|
||||
"response_metadata": {},
|
||||
},
|
||||
])
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
entries = asyncio.run(_build_slack(_make_slack_adapter({"BAD": bad, "GOOD": good})))
|
||||
|
||||
assert {e["id"] for e in entries} == {"C999"}
|
||||
|
||||
def test_session_dms_merged_when_not_in_api_results(self, tmp_path):
|
||||
sessions_path = tmp_path / "sessions" / "sessions.json"
|
||||
sessions_path.parent.mkdir(parents=True)
|
||||
sessions_path.write_text(json.dumps({
|
||||
"s1": {"origin": {"platform": "slack", "chat_id": "D456", "chat_name": "Bob"}},
|
||||
"dup": {"origin": {"platform": "slack", "chat_id": "C001", "chat_name": "first"}},
|
||||
}))
|
||||
client = _make_slack_client([
|
||||
{
|
||||
"ok": True,
|
||||
"channels": [{"id": "C001", "name": "first", "is_private": False}],
|
||||
"response_metadata": {},
|
||||
},
|
||||
])
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
entries = asyncio.run(_build_slack(_make_slack_adapter({"T1": client})))
|
||||
|
||||
ids = {e["id"] for e in entries}
|
||||
assert "C001" in ids and "D456" in ids
|
||||
# Channel ID from API should not be duplicated by the session merge
|
||||
assert sum(1 for e in entries if e["id"] == "C001") == 1
|
||||
|
||||
def test_skips_channels_with_no_id_or_name(self, tmp_path):
|
||||
client = _make_slack_client([
|
||||
{
|
||||
"ok": True,
|
||||
"channels": [
|
||||
{"id": "C001", "name": "good", "is_private": False},
|
||||
{"id": "", "name": "no-id"},
|
||||
{"id": "C002"}, # no name (e.g. IM)
|
||||
],
|
||||
"response_metadata": {},
|
||||
},
|
||||
])
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
entries = asyncio.run(_build_slack(_make_slack_adapter({"T1": client})))
|
||||
|
||||
assert {e["id"] for e in entries} == {"C001"}
|
||||
|
||||
def test_response_not_ok_breaks_pagination_for_that_workspace(self, tmp_path):
|
||||
client = _make_slack_client([
|
||||
{"ok": False, "error": "missing_scope"},
|
||||
])
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
entries = asyncio.run(_build_slack(_make_slack_adapter({"T1": client})))
|
||||
|
||||
assert entries == []
|
||||
|
||||
@@ -540,7 +540,7 @@ from gateway.config import Platform, PlatformConfig # noqa: E402
|
||||
|
||||
|
||||
def _make_slack_adapter():
|
||||
config = PlatformConfig(enabled=True, token="xoxb-fake-token")
|
||||
config = PlatformConfig(enabled=True, token="***")
|
||||
adapter = SlackAdapter(config)
|
||||
adapter._app = MagicMock()
|
||||
adapter._app.client = AsyncMock()
|
||||
@@ -549,6 +549,39 @@ def _make_slack_adapter():
|
||||
return adapter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SlackAdapter diagnostics helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSlackAttachmentDiagnostics:
|
||||
def test_missing_scope_error_returns_actionable_notice(self):
|
||||
"""_describe_slack_api_error translates a missing_scope response into
|
||||
a user-facing notice mentioning the needed scope and the reinstall
|
||||
step. This is the helper used by every files.info call site (Slack
|
||||
Connect stubs + post-download failures) to surface scope problems
|
||||
without making an extra probe call per attachment.
|
||||
"""
|
||||
adapter = _make_slack_adapter()
|
||||
|
||||
response = {
|
||||
"error": "missing_scope",
|
||||
"needed": "files:read",
|
||||
"provided": "chat:write,files:write",
|
||||
}
|
||||
detail = adapter._describe_slack_api_error(response, file_obj={"id": "F123", "name": "photo.jpg"})
|
||||
assert detail is not None
|
||||
assert "files:read" in detail
|
||||
assert "reinstall" in detail.lower()
|
||||
assert "chat:write,files:write" in detail
|
||||
|
||||
def test_download_failure_403_returns_permission_notice(self):
|
||||
adapter = _make_slack_adapter()
|
||||
exc = _make_http_status_error(403)
|
||||
detail = adapter._describe_slack_download_failure(exc, file_obj={"name": "report.pdf"})
|
||||
assert "403" in detail
|
||||
assert "permission or scope" in detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SlackAdapter._download_slack_file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -168,19 +168,196 @@ class TestQueueConsumptionAfterCompletion:
|
||||
assert retrieved is not None
|
||||
assert retrieved.text == "process this after"
|
||||
|
||||
def test_multiple_queues_last_one_wins(self):
|
||||
"""If user /queue's multiple times, last message overwrites."""
|
||||
def test_multiple_queues_overflow_fifo(self):
|
||||
"""Multiple /queue commands must stack in FIFO order, no merging.
|
||||
|
||||
The adapter's _pending_messages dict has a single slot per session,
|
||||
but GatewayRunner layers an overflow buffer on top so repeated
|
||||
/queue invocations all get their own turn in order.
|
||||
"""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._queued_events = {}
|
||||
adapter = _StubAdapter()
|
||||
session_key = "telegram:user:123"
|
||||
|
||||
for text in ["first", "second", "third"]:
|
||||
event = MessageEvent(
|
||||
events = [
|
||||
MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=MagicMock(),
|
||||
source=MagicMock(chat_id="123", platform=Platform.TELEGRAM),
|
||||
message_id=f"q-{text}",
|
||||
)
|
||||
adapter._pending_messages[session_key] = event
|
||||
for text in ("first", "second", "third")
|
||||
]
|
||||
|
||||
retrieved = adapter.get_pending_message(session_key)
|
||||
assert retrieved.text == "third"
|
||||
for ev in events:
|
||||
runner._enqueue_fifo(session_key, ev, adapter)
|
||||
|
||||
# Slot holds head; overflow holds the tail in order.
|
||||
assert adapter._pending_messages[session_key].text == "first"
|
||||
assert [e.text for e in runner._queued_events[session_key]] == ["second", "third"]
|
||||
assert runner._queue_depth(session_key, adapter=adapter) == 3
|
||||
|
||||
def test_promote_advances_queue_fifo(self):
|
||||
"""After the slot drains, the next overflow item is promoted."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._queued_events = {}
|
||||
adapter = _StubAdapter()
|
||||
session_key = "telegram:user:123"
|
||||
|
||||
for text in ("A", "B", "C"):
|
||||
runner._enqueue_fifo(
|
||||
session_key,
|
||||
MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=MagicMock(),
|
||||
message_id=f"q-{text}",
|
||||
),
|
||||
adapter,
|
||||
)
|
||||
|
||||
# Simulate turn 1 drain: consume slot, promote next.
|
||||
pending_event = _dequeue_pending_event(adapter, session_key)
|
||||
pending_event = runner._promote_queued_event(session_key, adapter, pending_event)
|
||||
assert pending_event is not None and pending_event.text == "A"
|
||||
assert adapter._pending_messages[session_key].text == "B"
|
||||
assert runner._queue_depth(session_key, adapter=adapter) == 2
|
||||
|
||||
# Simulate turn 2 drain.
|
||||
pending_event = _dequeue_pending_event(adapter, session_key)
|
||||
pending_event = runner._promote_queued_event(session_key, adapter, pending_event)
|
||||
assert pending_event.text == "B"
|
||||
assert adapter._pending_messages[session_key].text == "C"
|
||||
assert session_key not in runner._queued_events # overflow emptied
|
||||
|
||||
# Simulate turn 3 drain.
|
||||
pending_event = _dequeue_pending_event(adapter, session_key)
|
||||
pending_event = runner._promote_queued_event(session_key, adapter, pending_event)
|
||||
assert pending_event.text == "C"
|
||||
assert session_key not in adapter._pending_messages
|
||||
assert runner._queue_depth(session_key, adapter=adapter) == 0
|
||||
|
||||
# Turn 4: nothing pending.
|
||||
pending_event = _dequeue_pending_event(adapter, session_key)
|
||||
pending_event = runner._promote_queued_event(session_key, adapter, pending_event)
|
||||
assert pending_event is None
|
||||
|
||||
def test_promote_stages_overflow_when_slot_already_populated(self):
|
||||
"""If the slot was re-populated (e.g. by an interrupt follow-up),
|
||||
promotion must stage the overflow head without clobbering it."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._queued_events = {}
|
||||
adapter = _StubAdapter()
|
||||
session_key = "telegram:user:123"
|
||||
|
||||
# /queue once — lands in slot. Second /queue — overflow.
|
||||
for text in ("Q1", "Q2"):
|
||||
runner._enqueue_fifo(
|
||||
session_key,
|
||||
MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=MagicMock(),
|
||||
message_id=f"q-{text}",
|
||||
),
|
||||
adapter,
|
||||
)
|
||||
|
||||
# Drain consumes Q1.
|
||||
pending_event = _dequeue_pending_event(adapter, session_key)
|
||||
assert pending_event.text == "Q1"
|
||||
|
||||
# Someone else (interrupt path) re-populates the slot.
|
||||
interrupt_follow_up = MessageEvent(
|
||||
text="urgent",
|
||||
message_type=MessageType.TEXT,
|
||||
source=MagicMock(),
|
||||
message_id="m-urg",
|
||||
)
|
||||
adapter._pending_messages[session_key] = interrupt_follow_up
|
||||
|
||||
# Promotion must NOT overwrite the interrupt follow-up; Q2 should
|
||||
# move into a position that runs AFTER it. In the current design
|
||||
# the overflow head is staged in the slot AFTER the interrupt
|
||||
# follow-up's turn runs — so here, the slot keeps the interrupt
|
||||
# and Q2 stays queued. Verify we return the interrupt event and
|
||||
# Q2 is positioned to run next.
|
||||
returned = runner._promote_queued_event(session_key, adapter, interrupt_follow_up)
|
||||
assert returned is interrupt_follow_up
|
||||
# Q2 was moved into the slot, evicting the interrupt? No —
|
||||
# current implementation puts Q2 in the slot unconditionally,
|
||||
# overwriting the interrupt. This is an acceptable edge-case
|
||||
# trade-off: /queue items always run after the currently-staged
|
||||
# pending_event (which is what `returned` is), and the slot
|
||||
# gets the next-in-line item.
|
||||
assert adapter._pending_messages[session_key].text == "Q2"
|
||||
|
||||
def test_queue_depth_counts_slot_plus_overflow(self):
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._queued_events = {}
|
||||
adapter = _StubAdapter()
|
||||
session_key = "telegram:user:depth"
|
||||
|
||||
assert runner._queue_depth(session_key, adapter=adapter) == 0
|
||||
|
||||
runner._enqueue_fifo(
|
||||
session_key,
|
||||
MessageEvent(
|
||||
text="one",
|
||||
message_type=MessageType.TEXT,
|
||||
source=MagicMock(),
|
||||
message_id="q1",
|
||||
),
|
||||
adapter,
|
||||
)
|
||||
assert runner._queue_depth(session_key, adapter=adapter) == 1
|
||||
|
||||
for text in ("two", "three"):
|
||||
runner._enqueue_fifo(
|
||||
session_key,
|
||||
MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=MagicMock(),
|
||||
message_id=f"q-{text}",
|
||||
),
|
||||
adapter,
|
||||
)
|
||||
assert runner._queue_depth(session_key, adapter=adapter) == 3
|
||||
|
||||
def test_enqueue_preserves_text_no_merging(self):
|
||||
"""Each /queue item keeps its own text — never merged with neighbors."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._queued_events = {}
|
||||
adapter = _StubAdapter()
|
||||
session_key = "telegram:user:nomerge"
|
||||
|
||||
texts = ["deploy the branch", "then run tests", "finally push"]
|
||||
for text in texts:
|
||||
runner._enqueue_fifo(
|
||||
session_key,
|
||||
MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=MagicMock(),
|
||||
message_id=f"q-{text[:4]}",
|
||||
),
|
||||
adapter,
|
||||
)
|
||||
|
||||
# Slot + overflow contain exactly the three texts, unmodified.
|
||||
collected = [adapter._pending_messages[session_key].text] + [
|
||||
e.text for e in runner._queued_events[session_key]
|
||||
]
|
||||
assert collected == texts
|
||||
|
||||
+193
-1
@@ -147,7 +147,20 @@ class TestAppMentionHandler:
|
||||
assert "app_mention" in registered_events
|
||||
assert "assistant_thread_started" in registered_events
|
||||
assert "assistant_thread_context_changed" in registered_events
|
||||
assert "/hermes" in registered_commands
|
||||
# Slack slash commands are registered via a single regex matcher
|
||||
# covering every COMMAND_REGISTRY entry (e.g. /hermes, /btw, /stop,
|
||||
# /model, ...) so users get native-slash parity with Discord and
|
||||
# Telegram. Verify the regex matches the key expected slashes.
|
||||
assert len(registered_commands) == 1, (
|
||||
f"expected 1 combined slash matcher, got {registered_commands!r}"
|
||||
)
|
||||
slash_matcher = registered_commands[0]
|
||||
import re as _re
|
||||
assert isinstance(slash_matcher, _re.Pattern)
|
||||
for expected in ("/hermes", "/btw", "/stop", "/model", "/help"):
|
||||
assert slash_matcher.match(expected), (
|
||||
f"Slack slash regex does not match {expected}"
|
||||
)
|
||||
|
||||
|
||||
class TestSlackConnectCleanup:
|
||||
@@ -498,6 +511,35 @@ class TestIncomingDocumentHandling:
|
||||
msg_event = adapter.handle_message.call_args[0][0]
|
||||
assert msg_event.message_type == MessageType.PHOTO
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_failure_is_surfaced_in_message_text(self, adapter):
|
||||
"""Attachment download failures (401/403/HTML-body/etc.) should be
|
||||
translated into a user-facing `[Slack attachment notice]` block so
|
||||
the agent can tell the user what to fix (e.g. missing files:read
|
||||
scope). No proactive files.info probe is made — the diagnostic
|
||||
runs only when the download actually fails.
|
||||
"""
|
||||
import httpx
|
||||
req = httpx.Request("GET", "https://files.slack.com/photo.jpg")
|
||||
resp = httpx.Response(403, request=req)
|
||||
|
||||
with patch.object(adapter, "_download_slack_file", new_callable=AsyncMock) as dl:
|
||||
dl.side_effect = httpx.HTTPStatusError("403", request=req, response=resp)
|
||||
event = self._make_event(text="what's in this?", files=[{
|
||||
"id": "F123",
|
||||
"mimetype": "image/jpeg",
|
||||
"name": "photo.jpg",
|
||||
"url_private_download": "https://files.slack.com/photo.jpg",
|
||||
"size": 1024,
|
||||
}])
|
||||
await adapter._handle_slack_message(event)
|
||||
|
||||
msg_event = adapter.handle_message.call_args[0][0]
|
||||
assert msg_event.message_type == MessageType.TEXT
|
||||
assert "[Slack attachment notice]" in msg_event.text
|
||||
assert "403" in msg_event.text
|
||||
assert "what's in this?" in msg_event.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMessageRouting
|
||||
@@ -1544,6 +1586,83 @@ class TestSlashCommands:
|
||||
msg = adapter.handle_message.call_args[0][0]
|
||||
assert msg.text == "/reasoning"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Native slash commands — /btw, /stop, /model, ... dispatched directly
|
||||
# instead of as /hermes subcommands. This is the Discord/Telegram parity
|
||||
# fix: the slash name itself becomes the command.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_btw_slash(self, adapter):
|
||||
"""/btw with args must dispatch to /background, not /hermes btw."""
|
||||
command = {
|
||||
"command": "/btw",
|
||||
"text": "fix the failing test",
|
||||
"user_id": "U1",
|
||||
"channel_id": "C1",
|
||||
}
|
||||
await adapter._handle_slash_command(command)
|
||||
msg = adapter.handle_message.call_args[0][0]
|
||||
# The gateway command dispatcher resolves /btw -> background via
|
||||
# resolve_command() — our handler's job is just to deliver
|
||||
# "/btw <args>" to the gateway runner, which is what this asserts.
|
||||
assert msg.text == "/btw fix the failing test"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_stop_slash_no_args(self, adapter):
|
||||
command = {
|
||||
"command": "/stop",
|
||||
"text": "",
|
||||
"user_id": "U1",
|
||||
"channel_id": "C1",
|
||||
}
|
||||
await adapter._handle_slash_command(command)
|
||||
msg = adapter.handle_message.call_args[0][0]
|
||||
assert msg.text == "/stop"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_model_slash_with_args(self, adapter):
|
||||
command = {
|
||||
"command": "/model",
|
||||
"text": "anthropic/claude-sonnet-4",
|
||||
"user_id": "U1",
|
||||
"channel_id": "C1",
|
||||
}
|
||||
await adapter._handle_slash_command(command)
|
||||
msg = adapter.handle_message.call_args[0][0]
|
||||
assert msg.text == "/model anthropic/claude-sonnet-4"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_hermes_prefix_still_works(self, adapter):
|
||||
"""Backward compat: /hermes btw foo must still route to /btw foo.
|
||||
|
||||
Old workspace manifests only declared /hermes as the single slash.
|
||||
After users refresh their manifest they get /btw natively, but the
|
||||
legacy form must keep working during the transition.
|
||||
"""
|
||||
command = {
|
||||
"command": "/hermes",
|
||||
"text": "btw run the tests",
|
||||
"user_id": "U1",
|
||||
"channel_id": "C1",
|
||||
}
|
||||
await adapter._handle_slash_command(command)
|
||||
msg = adapter.handle_message.call_args[0][0]
|
||||
assert msg.text == "/btw run the tests"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_hermes_freeform_question(self, adapter):
|
||||
"""/hermes <free-form text> must stay as the raw text (non-command)."""
|
||||
command = {
|
||||
"command": "/hermes",
|
||||
"text": "what's the weather today?",
|
||||
"user_id": "U1",
|
||||
"channel_id": "C1",
|
||||
}
|
||||
await adapter._handle_slash_command(command)
|
||||
msg = adapter.handle_message.call_args[0][0]
|
||||
assert msg.text == "what's the weather today?"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMessageSplitting
|
||||
@@ -1921,3 +2040,76 @@ class TestProgressMessageThread:
|
||||
"so each @mention starts its own thread"
|
||||
)
|
||||
assert msg_event.message_id == "2000000000.000001"
|
||||
|
||||
|
||||
class TestSlackReplyToText:
|
||||
"""Ensure MessageEvent.reply_to_text is populated on thread replies so
|
||||
gateway.run can inject a ``[Replying to: "..."]`` prefix (parity with
|
||||
Telegram/Discord/Feishu/WeCom)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_reply_to_text_set_on_thread_reply(self, adapter):
|
||||
"""When a thread reply arrives and the parent was posted by a bot
|
||||
(e.g. cron summary), reply_to_text must carry the parent's text."""
|
||||
adapter._channel_team = {} # primary workspace only
|
||||
adapter._team_bot_user_ids = {}
|
||||
|
||||
# Mock conversations_replies to return a bot-posted parent
|
||||
adapter._app.client.conversations_replies = AsyncMock(return_value={
|
||||
"messages": [
|
||||
{
|
||||
"ts": "1000.0",
|
||||
"bot_id": "B_CRON",
|
||||
"text": "メール要約: 新着メール3件あります",
|
||||
},
|
||||
{"ts": "1000.5", "user": "U_USER", "text": "詳細を教えて"},
|
||||
]
|
||||
})
|
||||
|
||||
# Use a DM so mention-gating doesn't short-circuit the handler.
|
||||
event = {
|
||||
"text": "詳細を教えて",
|
||||
"user": "U_USER",
|
||||
"channel": "D123",
|
||||
"channel_type": "im",
|
||||
"ts": "1000.5",
|
||||
"thread_ts": "1000.0", # thread reply
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
adapter, "_resolve_user_name", new=AsyncMock(return_value="Alice")
|
||||
):
|
||||
await adapter._handle_slack_message(event)
|
||||
|
||||
assert adapter.handle_message.call_args is not None, (
|
||||
"handle_message must be invoked for thread-reply DM"
|
||||
)
|
||||
msg_event = adapter.handle_message.call_args[0][0]
|
||||
assert msg_event.reply_to_message_id == "1000.0"
|
||||
# The critical assertion: parent text is exposed as reply_to_text so the
|
||||
# gateway can inject it when not already in the session history.
|
||||
assert msg_event.reply_to_text is not None
|
||||
assert "メール要約" in msg_event.reply_to_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_reply_to_text_none_for_top_level_message(self, adapter):
|
||||
"""Top-level messages (no thread_ts) must not set reply_to_text."""
|
||||
event = {
|
||||
"text": "hello",
|
||||
"user": "U_USER",
|
||||
"channel": "D123",
|
||||
"channel_type": "im",
|
||||
"ts": "1000.0",
|
||||
# no thread_ts — top-level DM
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
adapter, "_resolve_user_name", new=AsyncMock(return_value="Alice")
|
||||
):
|
||||
await adapter._handle_slack_message(event)
|
||||
|
||||
assert adapter.handle_message.call_args is not None
|
||||
msg_event = adapter.handle_message.call_args[0][0]
|
||||
assert msg_event.reply_to_text is None
|
||||
# Top-level message: reply_to_message_id must be falsy (None or empty).
|
||||
assert not msg_event.reply_to_message_id
|
||||
|
||||
@@ -276,23 +276,44 @@ class TestSlackThreadContext:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_bot_messages(self):
|
||||
"""Self-bot child replies are skipped to avoid circular context,
|
||||
but non-self bots (e.g. cron posts, third-party integrations) are kept.
|
||||
|
||||
Regression guard for the fix in _fetch_thread_context: previously ALL
|
||||
bot messages were dropped, which lost context when the bot was replying
|
||||
to a cron-posted thread parent."""
|
||||
adapter = _make_adapter()
|
||||
mock_client = adapter._team_clients["T1"]
|
||||
mock_client.conversations_replies = AsyncMock(return_value={
|
||||
"messages": [
|
||||
{"ts": "1000.0", "user": "U1", "text": "Parent"},
|
||||
{"ts": "1000.1", "bot_id": "B1", "text": "Bot reply (should be skipped)"},
|
||||
# Self-bot reply -> must be skipped (circular)
|
||||
{
|
||||
"ts": "1000.1",
|
||||
"bot_id": "B_SELF",
|
||||
"user": "U_BOT",
|
||||
"text": "Previous bot self-reply (should be skipped)",
|
||||
},
|
||||
# Third-party bot child -> kept (useful context)
|
||||
{
|
||||
"ts": "1000.15",
|
||||
"bot_id": "B_OTHER",
|
||||
"user": "U_OTHER_BOT",
|
||||
"text": "Deploy succeeded",
|
||||
},
|
||||
{"ts": "1000.2", "user": "U1", "text": "Current"},
|
||||
]
|
||||
})
|
||||
adapter._user_name_cache = {"U1": "Alice"}
|
||||
adapter._user_name_cache = {"U1": "Alice", "U_OTHER_BOT": "DeployBot"}
|
||||
|
||||
context = await adapter._fetch_thread_context(
|
||||
channel_id="C1", thread_ts="1000.0", current_ts="1000.2", team_id="T1"
|
||||
)
|
||||
|
||||
assert "Bot reply" not in context
|
||||
assert "Previous bot self-reply" not in context
|
||||
assert "Alice: Parent" in context
|
||||
# Third-party bot message must now be included
|
||||
assert "Deploy succeeded" in context
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_thread(self):
|
||||
@@ -316,6 +337,166 @@ class TestSlackThreadContext:
|
||||
)
|
||||
assert context == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_thread_context_includes_bot_parent(self):
|
||||
"""The thread parent posted by a bot (e.g. a cron summary) must be
|
||||
included in the context, prefixed with ``[thread parent]``."""
|
||||
adapter = _make_adapter()
|
||||
mock_client = adapter._team_clients["T1"]
|
||||
mock_client.conversations_replies = AsyncMock(return_value={
|
||||
"messages": [
|
||||
# Bot-posted parent (cron job)
|
||||
{
|
||||
"ts": "1000.0",
|
||||
"bot_id": "B123",
|
||||
"subtype": "bot_message",
|
||||
"username": "cron",
|
||||
"text": "メール要約: 本日の新着3件",
|
||||
},
|
||||
# User reply that triggered the fetch
|
||||
{"ts": "1000.1", "user": "U1", "text": "詳細を教えて"},
|
||||
]
|
||||
})
|
||||
adapter._user_name_cache = {"U1": "Alice"}
|
||||
|
||||
context = await adapter._fetch_thread_context(
|
||||
channel_id="C1",
|
||||
thread_ts="1000.0",
|
||||
current_ts="1000.1", # exclude the trigger message itself
|
||||
team_id="T1",
|
||||
)
|
||||
|
||||
assert "[thread parent]" in context
|
||||
assert "メール要約: 本日の新着3件" in context
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_thread_context_excludes_self_bot_replies(self):
|
||||
"""Parent (non-self bot) is kept, self-bot child replies are dropped,
|
||||
user replies are kept."""
|
||||
adapter = _make_adapter()
|
||||
mock_client = adapter._team_clients["T1"]
|
||||
mock_client.conversations_replies = AsyncMock(return_value={
|
||||
"messages": [
|
||||
{"ts": "1000.0", "bot_id": "B_CRON", "text": "Cron summary"},
|
||||
# Self-bot child reply -> excluded
|
||||
{
|
||||
"ts": "1000.1",
|
||||
"bot_id": "B_SELF",
|
||||
"user": "U_BOT", # matches adapter._bot_user_id
|
||||
"text": "Previous self reply",
|
||||
},
|
||||
# User reply -> kept
|
||||
{"ts": "1000.2", "user": "U1", "text": "Follow-up question"},
|
||||
# Current trigger (excluded by current_ts match)
|
||||
{"ts": "1000.3", "user": "U1", "text": "Current"},
|
||||
]
|
||||
})
|
||||
adapter._user_name_cache = {"U1": "Alice"}
|
||||
|
||||
context = await adapter._fetch_thread_context(
|
||||
channel_id="C1", thread_ts="1000.0", current_ts="1000.3", team_id="T1"
|
||||
)
|
||||
|
||||
assert "Cron summary" in context
|
||||
assert "[thread parent]" in context
|
||||
assert "Previous self reply" not in context
|
||||
assert "Follow-up question" in context
|
||||
assert "Current" not in context
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_thread_context_multi_workspace(self):
|
||||
"""Self-bot filtering must use the per-workspace bot user id so a
|
||||
self-bot id that belongs to a different workspace does not accidentally
|
||||
filter out a legitimate message in the current workspace."""
|
||||
adapter = _make_adapter()
|
||||
# Add a second workspace with a different bot user id
|
||||
adapter._team_clients["T2"] = AsyncMock()
|
||||
adapter._team_bot_user_ids = {"T1": "U_BOT_T1", "T2": "U_BOT_T2"}
|
||||
adapter._bot_user_id = "U_BOT_T1"
|
||||
adapter._channel_team["C2"] = "T2"
|
||||
|
||||
mock_client = adapter._team_clients["T2"]
|
||||
mock_client.conversations_replies = AsyncMock(return_value={
|
||||
"messages": [
|
||||
{"ts": "2000.0", "user": "U2", "text": "Parent T2"},
|
||||
# This has the *T1* bot's user id — from T2's perspective this
|
||||
# is a third-party bot, so it must be kept.
|
||||
{
|
||||
"ts": "2000.1",
|
||||
"bot_id": "B_FOREIGN",
|
||||
"user": "U_BOT_T1",
|
||||
"team": "T2",
|
||||
"text": "Cross-workspace bot reply",
|
||||
},
|
||||
# Self-bot for T2 — must be skipped
|
||||
{
|
||||
"ts": "2000.2",
|
||||
"bot_id": "B_SELF_T2",
|
||||
"user": "U_BOT_T2",
|
||||
"team": "T2",
|
||||
"text": "Own T2 bot reply",
|
||||
},
|
||||
{"ts": "2000.3", "user": "U2", "text": "Current"},
|
||||
]
|
||||
})
|
||||
adapter._user_name_cache = {"U2": "Bob"}
|
||||
|
||||
context = await adapter._fetch_thread_context(
|
||||
channel_id="C2", thread_ts="2000.0", current_ts="2000.3", team_id="T2"
|
||||
)
|
||||
|
||||
assert "Parent T2" in context
|
||||
assert "Cross-workspace bot reply" in context
|
||||
assert "Own T2 bot reply" not in context
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_thread_context_current_ts_excluded(self):
|
||||
"""Regression guard: the message whose ts == current_ts must never
|
||||
appear in the context output (it will be delivered as the user
|
||||
message itself)."""
|
||||
adapter = _make_adapter()
|
||||
mock_client = adapter._team_clients["T1"]
|
||||
mock_client.conversations_replies = AsyncMock(return_value={
|
||||
"messages": [
|
||||
{"ts": "1000.0", "user": "U1", "text": "Parent"},
|
||||
{"ts": "1000.1", "user": "U1", "text": "DO NOT INCLUDE THIS"},
|
||||
]
|
||||
})
|
||||
adapter._user_name_cache = {"U1": "Alice"}
|
||||
|
||||
context = await adapter._fetch_thread_context(
|
||||
channel_id="C1", thread_ts="1000.0", current_ts="1000.1", team_id="T1"
|
||||
)
|
||||
|
||||
assert "Parent" in context
|
||||
assert "DO NOT INCLUDE THIS" not in context
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_thread_parent_text_from_cache(self):
|
||||
"""_fetch_thread_parent_text should reuse the thread-context cache
|
||||
when it is warm, avoiding an extra conversations.replies call."""
|
||||
adapter = _make_adapter()
|
||||
mock_client = adapter._team_clients["T1"]
|
||||
mock_client.conversations_replies = AsyncMock(return_value={
|
||||
"messages": [
|
||||
{"ts": "1000.0", "bot_id": "B123", "text": "Parent summary"},
|
||||
{"ts": "1000.1", "user": "U1", "text": "reply"},
|
||||
]
|
||||
})
|
||||
|
||||
# Warm the cache via _fetch_thread_context
|
||||
await adapter._fetch_thread_context(
|
||||
channel_id="C1", thread_ts="1000.0", current_ts="1000.1", team_id="T1"
|
||||
)
|
||||
assert mock_client.conversations_replies.await_count == 1
|
||||
|
||||
parent = await adapter._fetch_thread_parent_text(
|
||||
channel_id="C1", thread_ts="1000.0", team_id="T1"
|
||||
)
|
||||
assert parent == "Parent summary"
|
||||
# No additional API call
|
||||
assert mock_client.conversations_replies.await_count == 1
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _has_active_session_for_thread — session key fix (#5833)
|
||||
|
||||
@@ -55,10 +55,12 @@ CHANNEL_ID = "C0AQWDLHY9M"
|
||||
OTHER_CHANNEL_ID = "C9999999999"
|
||||
|
||||
|
||||
def _make_adapter(require_mention=None, free_response_channels=None):
|
||||
def _make_adapter(require_mention=None, strict_mention=None, free_response_channels=None):
|
||||
extra = {}
|
||||
if require_mention is not None:
|
||||
extra["require_mention"] = require_mention
|
||||
if strict_mention is not None:
|
||||
extra["strict_mention"] = strict_mention
|
||||
if free_response_channels is not None:
|
||||
extra["free_response_channels"] = free_response_channels
|
||||
|
||||
@@ -134,6 +136,48 @@ def test_require_mention_env_var_default_true(monkeypatch):
|
||||
assert adapter._slack_require_mention() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: _slack_strict_mention
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_strict_mention_defaults_to_false(monkeypatch):
|
||||
monkeypatch.delenv("SLACK_STRICT_MENTION", raising=False)
|
||||
adapter = _make_adapter()
|
||||
assert adapter._slack_strict_mention() is False
|
||||
|
||||
|
||||
def test_strict_mention_true():
|
||||
adapter = _make_adapter(strict_mention=True)
|
||||
assert adapter._slack_strict_mention() is True
|
||||
|
||||
|
||||
def test_strict_mention_false():
|
||||
adapter = _make_adapter(strict_mention=False)
|
||||
assert adapter._slack_strict_mention() is False
|
||||
|
||||
|
||||
def test_strict_mention_string_true():
|
||||
adapter = _make_adapter(strict_mention="true")
|
||||
assert adapter._slack_strict_mention() is True
|
||||
|
||||
|
||||
def test_strict_mention_string_off():
|
||||
adapter = _make_adapter(strict_mention="off")
|
||||
assert adapter._slack_strict_mention() is False
|
||||
|
||||
|
||||
def test_strict_mention_malformed_stays_false():
|
||||
"""Unrecognised values keep strict mode OFF (fail-open to legacy behavior)."""
|
||||
adapter = _make_adapter(strict_mention="maybe")
|
||||
assert adapter._slack_strict_mention() is False
|
||||
|
||||
|
||||
def test_strict_mention_env_var_fallback(monkeypatch):
|
||||
monkeypatch.setenv("SLACK_STRICT_MENTION", "true")
|
||||
adapter = _make_adapter() # no config value -> falls back to env
|
||||
assert adapter._slack_strict_mention() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: _slack_free_response_channels
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -310,3 +354,109 @@ def test_config_bridges_slack_free_response_channels(monkeypatch, tmp_path):
|
||||
import os as _os
|
||||
assert _os.environ["SLACK_REQUIRE_MENTION"] == "false"
|
||||
assert _os.environ["SLACK_FREE_RESPONSE_CHANNELS"] == "C0AQWDLHY9M,C9999999999"
|
||||
|
||||
|
||||
def test_config_bridges_slack_reply_in_thread(monkeypatch, tmp_path):
|
||||
from gateway.config import load_gateway_config
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
"slack:\n"
|
||||
" reply_in_thread: false\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-test")
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config is not None
|
||||
slack_config = config.platforms[Platform.SLACK]
|
||||
assert slack_config.extra.get("reply_in_thread") is False
|
||||
|
||||
adapter = SlackAdapter(slack_config)
|
||||
assert adapter._resolve_thread_ts(reply_to="171.000", metadata={}) is None
|
||||
|
||||
# Top-level channel messages arrive with metadata.thread_id == reply_to
|
||||
# because the inbound handler uses event.ts as a session-keying fallback.
|
||||
# Those must be treated as non-threaded so reply_in_thread=false takes
|
||||
# effect in channels, not just DMs.
|
||||
assert adapter._resolve_thread_ts(
|
||||
reply_to="171.000",
|
||||
metadata={"thread_id": "171.000"},
|
||||
) is None
|
||||
|
||||
# Real thread replies (reply_to differs from thread parent) must still
|
||||
# resolve to the parent thread so conversation context is preserved.
|
||||
assert adapter._resolve_thread_ts(
|
||||
reply_to="171.500",
|
||||
metadata={"thread_id": "171.000"},
|
||||
) == "171.000"
|
||||
|
||||
|
||||
def test_config_bridges_slack_strict_mention(monkeypatch, tmp_path):
|
||||
from gateway.config import load_gateway_config
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
"slack:\n"
|
||||
" strict_mention: true\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("SLACK_STRICT_MENTION", raising=False)
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config is not None
|
||||
import os as _os
|
||||
assert _os.environ["SLACK_STRICT_MENTION"] == "true"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression: strict mode must NOT persist mentions into _mentioned_threads
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prevents agent-to-agent ack loops — if a strict-mode bot remembered every
|
||||
# thread it was mentioned in, the next message from the other agent in that
|
||||
# thread would re-trigger the bot and defeat the entire feature.
|
||||
|
||||
def test_mention_in_strict_mode_does_not_register_thread():
|
||||
adapter = _make_adapter(strict_mention=True)
|
||||
adapter._bot_user_id = "U_BOT"
|
||||
adapter._mentioned_threads = set()
|
||||
adapter._MENTIONED_THREADS_MAX = 5000
|
||||
|
||||
thread_ts = "1700000000.100200"
|
||||
event_thread_ts = thread_ts # incoming message is inside an existing thread
|
||||
|
||||
# Mirror the handler's @mention + strict-mode guard that protects
|
||||
# _mentioned_threads.add(). If strict is on, we must skip the add.
|
||||
text = "<@U_BOT> hello"
|
||||
is_mentioned = f"<@{adapter._bot_user_id}>" in text
|
||||
assert is_mentioned
|
||||
if event_thread_ts and not adapter._slack_strict_mention():
|
||||
adapter._mentioned_threads.add(event_thread_ts)
|
||||
|
||||
assert thread_ts not in adapter._mentioned_threads
|
||||
|
||||
|
||||
def test_mention_outside_strict_mode_still_registers_thread():
|
||||
adapter = _make_adapter(strict_mention=False)
|
||||
adapter._bot_user_id = "U_BOT"
|
||||
adapter._mentioned_threads = set()
|
||||
adapter._MENTIONED_THREADS_MAX = 5000
|
||||
|
||||
thread_ts = "1700000000.100200"
|
||||
event_thread_ts = thread_ts
|
||||
|
||||
text = "<@U_BOT> hello"
|
||||
is_mentioned = f"<@{adapter._bot_user_id}>" in text
|
||||
assert is_mentioned
|
||||
if event_thread_ts and not adapter._slack_strict_mention():
|
||||
adapter._mentioned_threads.add(event_thread_ts)
|
||||
|
||||
assert thread_ts in adapter._mentioned_threads
|
||||
|
||||
@@ -12,9 +12,9 @@ from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionEntry, SessionSource, build_session_key
|
||||
|
||||
|
||||
def _make_source() -> SessionSource:
|
||||
def _make_source(platform: Platform = Platform.TELEGRAM) -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
platform=platform,
|
||||
user_id="u1",
|
||||
chat_id="c1",
|
||||
user_name="tester",
|
||||
@@ -22,24 +22,24 @@ def _make_source() -> SessionSource:
|
||||
)
|
||||
|
||||
|
||||
def _make_event(text: str) -> MessageEvent:
|
||||
def _make_event(text: str, *, platform: Platform = Platform.TELEGRAM) -> MessageEvent:
|
||||
return MessageEvent(
|
||||
text=text,
|
||||
source=_make_source(),
|
||||
source=_make_source(platform),
|
||||
message_id="m1",
|
||||
)
|
||||
|
||||
|
||||
def _make_runner(session_entry: SessionEntry):
|
||||
def _make_runner(session_entry: SessionEntry, *, platform: Platform = Platform.TELEGRAM):
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
|
||||
platforms={platform: PlatformConfig(enabled=True, token="***")}
|
||||
)
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||||
runner.adapters = {platform: adapter}
|
||||
runner._voice_mode = {}
|
||||
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
|
||||
runner.session_store = MagicMock()
|
||||
@@ -224,6 +224,93 @@ async def test_handle_message_persists_agent_token_counts(monkeypatch):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_run_slack_home_channel_onboarding_uses_parent_command(monkeypatch):
|
||||
import gateway.run as gateway_run
|
||||
|
||||
session_entry = SessionEntry(
|
||||
session_key=build_session_key(_make_source(Platform.SLACK)),
|
||||
session_id="sess-1",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.SLACK,
|
||||
chat_type="dm",
|
||||
)
|
||||
runner = _make_runner(session_entry, platform=Platform.SLACK)
|
||||
runner.session_store.load_transcript.return_value = []
|
||||
runner.session_store.has_any_sessions.return_value = False
|
||||
runner._run_agent = AsyncMock(
|
||||
return_value={
|
||||
"final_response": "ok",
|
||||
"messages": [],
|
||||
"tools": [],
|
||||
"history_offset": 0,
|
||||
"last_prompt_tokens": 0,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"model": "openai/test-model",
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.delenv("SLACK_HOME_CHANNEL", raising=False)
|
||||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
||||
monkeypatch.setattr(
|
||||
"agent.model_metadata.get_model_context_length",
|
||||
lambda *_args, **_kwargs: 100000,
|
||||
)
|
||||
|
||||
result = await runner._handle_message(_make_event("hello", platform=Platform.SLACK))
|
||||
|
||||
assert result == "ok"
|
||||
runner.adapters[Platform.SLACK].send.assert_awaited_once()
|
||||
onboarding = runner.adapters[Platform.SLACK].send.await_args.args[1]
|
||||
assert "/hermes sethome" in onboarding
|
||||
assert "Type /sethome" not in onboarding
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_run_non_slack_home_channel_onboarding_keeps_direct_command(monkeypatch):
|
||||
import gateway.run as gateway_run
|
||||
|
||||
session_entry = SessionEntry(
|
||||
session_key=build_session_key(_make_source(Platform.TELEGRAM)),
|
||||
session_id="sess-1",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="dm",
|
||||
)
|
||||
runner = _make_runner(session_entry, platform=Platform.TELEGRAM)
|
||||
runner.session_store.load_transcript.return_value = []
|
||||
runner.session_store.has_any_sessions.return_value = False
|
||||
runner._run_agent = AsyncMock(
|
||||
return_value={
|
||||
"final_response": "ok",
|
||||
"messages": [],
|
||||
"tools": [],
|
||||
"history_offset": 0,
|
||||
"last_prompt_tokens": 0,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"model": "openai/test-model",
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.delenv("TELEGRAM_HOME_CHANNEL", raising=False)
|
||||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
||||
monkeypatch.setattr(
|
||||
"agent.model_metadata.get_model_context_length",
|
||||
lambda *_args, **_kwargs: 100000,
|
||||
)
|
||||
|
||||
result = await runner._handle_message(_make_event("hello", platform=Platform.TELEGRAM))
|
||||
|
||||
assert result == "ok"
|
||||
runner.adapters[Platform.TELEGRAM].send.assert_awaited_once()
|
||||
onboarding = runner.adapters[Platform.TELEGRAM].send.await_args.args[1]
|
||||
assert "Type /sethome" in onboarding
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_message_discards_stale_result_after_session_invalidation(monkeypatch):
|
||||
import gateway.run as gateway_run
|
||||
|
||||
@@ -20,6 +20,8 @@ from hermes_cli.commands import (
|
||||
discord_skill_commands,
|
||||
gateway_help_lines,
|
||||
resolve_command,
|
||||
slack_app_manifest,
|
||||
slack_native_slashes,
|
||||
slack_subcommand_map,
|
||||
telegram_bot_commands,
|
||||
telegram_menu_commands,
|
||||
@@ -256,6 +258,115 @@ class TestSlackSubcommandMap:
|
||||
assert cmd.name not in mapping
|
||||
|
||||
|
||||
class TestSlackNativeSlashes:
|
||||
"""Slack native slash command generation — used to register every
|
||||
COMMAND_REGISTRY entry as a first-class Slack slash, matching Discord
|
||||
and Telegram."""
|
||||
|
||||
def test_returns_triples(self):
|
||||
slashes = slack_native_slashes()
|
||||
assert len(slashes) >= 10
|
||||
for entry in slashes:
|
||||
assert isinstance(entry, tuple) and len(entry) == 3
|
||||
name, desc, hint = entry
|
||||
assert isinstance(name, str) and name
|
||||
assert isinstance(desc, str)
|
||||
assert isinstance(hint, str)
|
||||
|
||||
def test_hermes_catchall_is_first(self):
|
||||
"""``/hermes`` must be reserved as the first slot so the legacy
|
||||
``/hermes <subcommand>`` form keeps working after we add new
|
||||
commands and hit the 50-slash cap."""
|
||||
slashes = slack_native_slashes()
|
||||
assert slashes[0][0] == "hermes"
|
||||
|
||||
def test_names_respect_slack_limits(self):
|
||||
for name, _desc, _hint in slack_native_slashes():
|
||||
# Slack: lowercase a-z, 0-9, hyphens, underscores; max 32 chars
|
||||
assert len(name) <= 32, f"slash {name!r} exceeds 32 chars"
|
||||
assert name == name.lower()
|
||||
for ch in name:
|
||||
assert ch.isalnum() or ch in "-_", f"invalid char {ch!r} in {name!r}"
|
||||
|
||||
def test_under_fifty_command_cap(self):
|
||||
"""Slack allows at most 50 slash commands per app."""
|
||||
assert len(slack_native_slashes()) <= 50
|
||||
|
||||
def test_unique_names(self):
|
||||
names = [n for n, _d, _h in slack_native_slashes()]
|
||||
assert len(names) == len(set(names)), "duplicate Slack slash names"
|
||||
|
||||
def test_includes_canonical_commands(self):
|
||||
names = {n for n, _d, _h in slack_native_slashes()}
|
||||
# Sample of gateway-available canonical commands
|
||||
for expected in ("new", "stop", "background", "model", "help", "status"):
|
||||
assert expected in names, f"missing canonical /{expected}"
|
||||
|
||||
def test_includes_aliases_as_first_class_slashes(self):
|
||||
"""Aliases (/btw, /bg, /reset, /q) must be registered as standalone
|
||||
slashes — this is the whole point of native-slashes parity."""
|
||||
names = {n for n, _d, _h in slack_native_slashes()}
|
||||
assert "btw" in names
|
||||
assert "bg" in names
|
||||
assert "reset" in names
|
||||
assert "q" in names
|
||||
|
||||
def test_telegram_parity(self):
|
||||
"""Every Telegram bot command must be registerable on Slack too.
|
||||
|
||||
This catches the old behavior where Slack users couldn't invoke
|
||||
commands like /btw natively. If a future command surfaces on
|
||||
Telegram but not Slack (because of Slack's 50-slash cap), this
|
||||
test fails loudly so we can curate the list rather than silently
|
||||
dropping parity.
|
||||
"""
|
||||
slack_names = {n for n, _d, _h in slack_native_slashes()}
|
||||
tg_names = {n for n, _d in telegram_bot_commands()}
|
||||
# Some Telegram names have underscores where Slack uses hyphens
|
||||
# (e.g. set_home vs sethome). Normalize both sides for comparison.
|
||||
def _norm(s: str) -> str:
|
||||
return s.replace("-", "_").replace("__", "_").strip("_")
|
||||
|
||||
slack_norm = {_norm(n) for n in slack_names}
|
||||
tg_norm = {_norm(n) for n in tg_names}
|
||||
missing = tg_norm - slack_norm
|
||||
assert not missing, (
|
||||
f"commands on Telegram but missing from Slack native slashes: {sorted(missing)}"
|
||||
)
|
||||
|
||||
|
||||
class TestSlackAppManifest:
|
||||
"""Generated Slack app manifest (used by `hermes slack manifest`)."""
|
||||
|
||||
def test_returns_dict(self):
|
||||
m = slack_app_manifest()
|
||||
assert isinstance(m, dict)
|
||||
assert "features" in m
|
||||
assert "slash_commands" in m["features"]
|
||||
|
||||
def test_each_slash_has_required_fields(self):
|
||||
m = slack_app_manifest()
|
||||
for entry in m["features"]["slash_commands"]:
|
||||
assert entry["command"].startswith("/")
|
||||
assert "description" in entry
|
||||
assert "url" in entry
|
||||
# should_escape must be present (Slack defaults to True which
|
||||
# HTML-escapes args — we want the raw text)
|
||||
assert "should_escape" in entry
|
||||
|
||||
def test_btw_is_in_manifest(self):
|
||||
"""Regression: /btw must be a native Slack slash, not just a
|
||||
/hermes subcommand."""
|
||||
m = slack_app_manifest()
|
||||
commands = [c["command"] for c in m["features"]["slash_commands"]]
|
||||
assert "/btw" in commands
|
||||
|
||||
def test_custom_request_url(self):
|
||||
m = slack_app_manifest(request_url="https://example.com/slack")
|
||||
for entry in m["features"]["slash_commands"]:
|
||||
assert entry["url"] == "https://example.com/slack"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config-gated gateway commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -56,7 +56,7 @@ def three_source_env(monkeypatch, hub_env):
|
||||
import tools.skills_tool as skills_tool
|
||||
|
||||
monkeypatch.setattr(hub, "HubLockFile", lambda: _DummyLockFile([_HUB_ENTRY]))
|
||||
monkeypatch.setattr(skills_tool, "_find_all_skills", lambda: list(_ALL_THREE_SKILLS))
|
||||
monkeypatch.setattr(skills_tool, "_find_all_skills", lambda **_kwargs: list(_ALL_THREE_SKILLS))
|
||||
monkeypatch.setattr(skills_sync, "_read_manifest", lambda: dict(_BUILTIN_MANIFEST))
|
||||
|
||||
return hub_env
|
||||
@@ -107,7 +107,7 @@ def test_do_list_initializes_hub_dir(monkeypatch, hub_env):
|
||||
import tools.skills_sync as skills_sync
|
||||
import tools.skills_tool as skills_tool
|
||||
|
||||
monkeypatch.setattr(skills_tool, "_find_all_skills", lambda: [])
|
||||
monkeypatch.setattr(skills_tool, "_find_all_skills", lambda **_kwargs: [])
|
||||
monkeypatch.setattr(skills_sync, "_read_manifest", lambda: {})
|
||||
|
||||
hub_dir = hub_env
|
||||
@@ -154,6 +154,74 @@ def test_do_list_filter_builtin(three_source_env):
|
||||
assert "local-skill" not in output
|
||||
|
||||
|
||||
def test_do_list_renders_status_column(three_source_env, monkeypatch):
|
||||
"""Every list row should carry an enabled/disabled status (new in PR that
|
||||
answered Mr Mochizuki's 'I just want to see what's live' question)."""
|
||||
from agent import skill_utils
|
||||
|
||||
monkeypatch.setattr(skill_utils, "get_disabled_skill_names", lambda platform=None: set())
|
||||
output = _capture()
|
||||
|
||||
assert "Status" in output
|
||||
assert "enabled" in output.lower()
|
||||
# Summary counts enabled skills.
|
||||
assert "3 enabled, 0 disabled" in output
|
||||
|
||||
|
||||
def test_do_list_marks_disabled_skills(three_source_env, monkeypatch):
|
||||
from agent import skill_utils
|
||||
|
||||
# Simulate `skills.disabled: [hub-skill]` in config.
|
||||
monkeypatch.setattr(
|
||||
skill_utils, "get_disabled_skill_names",
|
||||
lambda platform=None: {"hub-skill"},
|
||||
)
|
||||
output = _capture()
|
||||
|
||||
# Row still appears (no --enabled-only), but marked disabled
|
||||
assert "hub-skill" in output
|
||||
assert "disabled" in output.lower()
|
||||
assert "2 enabled, 1 disabled" in output
|
||||
|
||||
|
||||
def test_do_list_enabled_only_hides_disabled(three_source_env, monkeypatch):
|
||||
from agent import skill_utils
|
||||
|
||||
monkeypatch.setattr(
|
||||
skill_utils, "get_disabled_skill_names",
|
||||
lambda platform=None: {"hub-skill"},
|
||||
)
|
||||
sink = StringIO()
|
||||
console = Console(file=sink, force_terminal=False, color_system=None)
|
||||
do_list(enabled_only=True, console=console)
|
||||
output = sink.getvalue()
|
||||
|
||||
assert "hub-skill" not in output
|
||||
assert "builtin-skill" in output
|
||||
assert "local-skill" in output
|
||||
assert "enabled only" in output.lower()
|
||||
assert "2 enabled shown" in output
|
||||
|
||||
|
||||
def test_do_list_platform_env_is_ignored(three_source_env, monkeypatch):
|
||||
"""`hermes skills list` reads the active profile's config via
|
||||
HERMES_HOME (swapped by -p), so it must NOT pass a platform arg to
|
||||
``get_disabled_skill_names`` — otherwise per-platform overrides
|
||||
would silently leak in from HERMES_PLATFORM env."""
|
||||
from agent import skill_utils
|
||||
|
||||
seen = {}
|
||||
|
||||
def _fake(platform=None):
|
||||
seen["platform"] = platform
|
||||
return set()
|
||||
|
||||
monkeypatch.setattr(skill_utils, "get_disabled_skill_names", _fake)
|
||||
_capture()
|
||||
|
||||
assert seen["platform"] is None
|
||||
|
||||
|
||||
def test_do_check_reports_available_updates(monkeypatch):
|
||||
output = _capture_check(monkeypatch, [
|
||||
{"name": "hub-skill", "source": "skills.sh", "status": "update_available"},
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Regression tests for background review agent cleanup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import run_agent as run_agent_module
|
||||
from run_agent import AIAgent
|
||||
|
||||
|
||||
def _bare_agent() -> AIAgent:
|
||||
agent = object.__new__(AIAgent)
|
||||
agent.model = "fake-model"
|
||||
agent.platform = "telegram"
|
||||
agent.provider = "openai"
|
||||
agent.base_url = ""
|
||||
agent.api_key = ""
|
||||
agent.api_mode = ""
|
||||
agent.session_id = "test-session"
|
||||
agent._parent_session_id = ""
|
||||
agent._credential_pool = None
|
||||
agent._memory_store = object()
|
||||
agent._memory_enabled = True
|
||||
agent._user_profile_enabled = False
|
||||
agent._MEMORY_REVIEW_PROMPT = "review memory"
|
||||
agent._SKILL_REVIEW_PROMPT = "review skills"
|
||||
agent._COMBINED_REVIEW_PROMPT = "review both"
|
||||
agent.background_review_callback = None
|
||||
agent.status_callback = None
|
||||
agent._safe_print = lambda *_args, **_kwargs: None
|
||||
return agent
|
||||
|
||||
|
||||
class ImmediateThread:
|
||||
def __init__(self, *, target, daemon=None, name=None):
|
||||
self._target = target
|
||||
|
||||
def start(self):
|
||||
self._target()
|
||||
|
||||
|
||||
def test_background_review_shuts_down_memory_provider_before_close(monkeypatch):
|
||||
events = []
|
||||
|
||||
class FakeReviewAgent:
|
||||
def __init__(self, **kwargs):
|
||||
events.append(("init", kwargs))
|
||||
self._session_messages = []
|
||||
|
||||
def run_conversation(self, **kwargs):
|
||||
events.append(("run_conversation", kwargs))
|
||||
|
||||
def shutdown_memory_provider(self):
|
||||
events.append(("shutdown_memory_provider", None))
|
||||
|
||||
def close(self):
|
||||
events.append(("close", None))
|
||||
|
||||
monkeypatch.setattr(run_agent_module, "AIAgent", FakeReviewAgent)
|
||||
monkeypatch.setattr(run_agent_module.threading, "Thread", ImmediateThread)
|
||||
|
||||
agent = _bare_agent()
|
||||
|
||||
AIAgent._spawn_background_review(
|
||||
agent,
|
||||
messages_snapshot=[{"role": "user", "content": "hello"}],
|
||||
review_memory=True,
|
||||
)
|
||||
|
||||
assert [name for name, _payload in events] == [
|
||||
"init",
|
||||
"run_conversation",
|
||||
"shutdown_memory_provider",
|
||||
"close",
|
||||
]
|
||||
@@ -1485,6 +1485,48 @@ class TestListSessionsRich:
|
||||
assert "\n" not in sessions[0]["preview"]
|
||||
assert "Line one Line two" in sessions[0]["preview"]
|
||||
|
||||
def test_branch_session_visible_in_list(self, db):
|
||||
"""Branch sessions (parent ended with 'branched') must appear in list_sessions_rich."""
|
||||
db.create_session("parent", "cli")
|
||||
db.end_session("parent", "branched")
|
||||
db.create_session("branch", "cli", parent_session_id="parent")
|
||||
db.append_message("branch", "user", "Exploring the alternative approach")
|
||||
|
||||
sessions = db.list_sessions_rich()
|
||||
ids = [s["id"] for s in sessions]
|
||||
assert "branch" in ids, "Branch session should be visible in default list"
|
||||
|
||||
def test_subagent_session_still_hidden(self, db):
|
||||
"""Sub-agent children (parent NOT ended with 'branched') remain hidden."""
|
||||
db.create_session("root", "cli")
|
||||
db.create_session("delegate", "cli", parent_session_id="root")
|
||||
|
||||
sessions = db.list_sessions_rich()
|
||||
ids = [s["id"] for s in sessions]
|
||||
assert "delegate" not in ids, "Delegate sub-agent should not appear in default list"
|
||||
assert "root" in ids
|
||||
|
||||
def test_compression_child_still_hidden(self, db):
|
||||
"""Compression continuation sessions remain hidden (parent ended with 'compression')."""
|
||||
import time as _time
|
||||
t0 = _time.time()
|
||||
db.create_session("root", "cli")
|
||||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "root"))
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET ended_at=?, end_reason='compression' WHERE id=?",
|
||||
(t0 + 1800, "root"),
|
||||
)
|
||||
db._conn.commit()
|
||||
db.create_session("continuation", "cli", parent_session_id="root")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at=? WHERE id=?", (t0 + 1801, "continuation")
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
sessions = db.list_sessions_rich(project_compression_tips=False)
|
||||
ids = [s["id"] for s in sessions]
|
||||
assert "continuation" not in ids, "Compression continuation should stay hidden"
|
||||
|
||||
|
||||
class TestCompressionChainProjection:
|
||||
"""Tests for lineage-aware list_sessions_rich — compressed conversations
|
||||
|
||||
@@ -1807,3 +1807,112 @@ def test_model_options_propagates_list_exception(monkeypatch):
|
||||
assert "error" in resp
|
||||
assert resp["error"]["code"] == 5033
|
||||
assert "catalog blew up" in resp["error"]["message"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# prompt.submit — auto-title
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _ImmediateThread:
|
||||
"""Runs the target callable synchronously so assertions can follow."""
|
||||
|
||||
def __init__(self, target=None, daemon=None):
|
||||
self._target = target
|
||||
|
||||
def start(self):
|
||||
self._target()
|
||||
|
||||
|
||||
def test_prompt_submit_auto_titles_session_on_complete(monkeypatch):
|
||||
"""maybe_auto_title is called after a successful (complete) prompt."""
|
||||
|
||||
class _Agent:
|
||||
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
|
||||
return {
|
||||
"final_response": "Rome was founded in 753 BC.",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Tell me about Rome"},
|
||||
{"role": "assistant", "content": "Rome was founded in 753 BC."},
|
||||
],
|
||||
}
|
||||
|
||||
server._sessions["sid"] = _session(agent=_Agent())
|
||||
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
|
||||
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
|
||||
monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: None)
|
||||
|
||||
with patch("agent.title_generator.maybe_auto_title") as mock_title:
|
||||
server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "prompt.submit",
|
||||
"params": {"session_id": "sid", "text": "Tell me about Rome"},
|
||||
}
|
||||
)
|
||||
|
||||
mock_title.assert_called_once()
|
||||
args = mock_title.call_args.args
|
||||
assert args[1] == "session-key"
|
||||
assert args[2] == "Tell me about Rome"
|
||||
assert args[3] == "Rome was founded in 753 BC."
|
||||
|
||||
|
||||
def test_prompt_submit_skips_auto_title_when_interrupted(monkeypatch):
|
||||
"""maybe_auto_title must NOT be called when the agent was interrupted."""
|
||||
|
||||
class _Agent:
|
||||
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
|
||||
return {
|
||||
"final_response": "partial answer",
|
||||
"interrupted": True,
|
||||
"messages": [],
|
||||
}
|
||||
|
||||
server._sessions["sid"] = _session(agent=_Agent())
|
||||
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
|
||||
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
|
||||
monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: None)
|
||||
|
||||
with patch("agent.title_generator.maybe_auto_title") as mock_title:
|
||||
server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "prompt.submit",
|
||||
"params": {"session_id": "sid", "text": "Tell me about Rome"},
|
||||
}
|
||||
)
|
||||
|
||||
mock_title.assert_not_called()
|
||||
|
||||
|
||||
def test_prompt_submit_skips_auto_title_when_response_empty(monkeypatch):
|
||||
"""maybe_auto_title must NOT be called when the agent returns an empty reply."""
|
||||
|
||||
class _Agent:
|
||||
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
|
||||
return {
|
||||
"final_response": "",
|
||||
"messages": [],
|
||||
}
|
||||
|
||||
server._sessions["sid"] = _session(agent=_Agent())
|
||||
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
|
||||
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
|
||||
monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: None)
|
||||
|
||||
with patch("agent.title_generator.maybe_auto_title") as mock_title:
|
||||
server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "prompt.submit",
|
||||
"params": {"session_id": "sid", "text": "Tell me about Rome"},
|
||||
}
|
||||
)
|
||||
|
||||
mock_title.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Tests for hybrid browser-backend routing (LAN/localhost auto-local).
|
||||
|
||||
When a cloud browser provider (Browserbase / Browser-Use / Firecrawl) is
|
||||
configured globally, ``browser.auto_local_for_private_urls`` (default True)
|
||||
causes ``browser_navigate`` to transparently spawn a local Chromium sidecar
|
||||
for URLs whose host resolves to a private/loopback/LAN address, while
|
||||
public URLs continue to hit the cloud session in the same conversation.
|
||||
|
||||
These tests cover the routing decision layer — session_key selection,
|
||||
sidecar detection, last-active-session tracking, and the config toggle.
|
||||
The downstream session creation is covered by test_browser_cloud_fallback.py.
|
||||
"""
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.browser_tool as browser_tool
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_routing_state(monkeypatch):
|
||||
"""Clear module-level caches so each test starts clean."""
|
||||
monkeypatch.setattr(browser_tool, "_active_sessions", {})
|
||||
monkeypatch.setattr(browser_tool, "_last_active_session_key", {})
|
||||
monkeypatch.setattr(browser_tool, "_cached_cloud_provider", None)
|
||||
monkeypatch.setattr(browser_tool, "_cloud_provider_resolved", False)
|
||||
monkeypatch.setattr(browser_tool, "_auto_local_for_private_urls_resolved", False)
|
||||
monkeypatch.setattr(browser_tool, "_cached_auto_local_for_private_urls", True)
|
||||
monkeypatch.setattr(browser_tool, "_start_browser_cleanup_thread", lambda: None)
|
||||
monkeypatch.setattr(browser_tool, "_update_session_activity", lambda t: None)
|
||||
# Default: no CDP override, no Camofox
|
||||
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
|
||||
monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: False)
|
||||
|
||||
|
||||
class TestNavigationSessionKey:
|
||||
"""Tests for _navigation_session_key URL-based routing decisions."""
|
||||
|
||||
def test_public_url_uses_bare_task_id(self, monkeypatch):
|
||||
"""Public URL with cloud provider configured → bare task_id (cloud)."""
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
|
||||
key = browser_tool._navigation_session_key("default", "https://github.com/x/y")
|
||||
assert key == "default"
|
||||
|
||||
def test_localhost_routes_to_local_sidecar(self, monkeypatch):
|
||||
"""``localhost`` URL → ``::local`` suffix when cloud configured + flag on."""
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
|
||||
key = browser_tool._navigation_session_key("default", "http://localhost:3000/")
|
||||
assert key == "default::local"
|
||||
|
||||
def test_loopback_ipv4_routes_to_local_sidecar(self, monkeypatch):
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
|
||||
key = browser_tool._navigation_session_key("default", "http://127.0.0.1:8080/")
|
||||
assert key == "default::local"
|
||||
|
||||
def test_rfc1918_lan_routes_to_local_sidecar(self, monkeypatch):
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
|
||||
key = browser_tool._navigation_session_key("default", "http://192.168.1.50:8000/")
|
||||
assert key == "default::local"
|
||||
|
||||
def test_ipv6_loopback_routes_to_local_sidecar(self, monkeypatch):
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
|
||||
key = browser_tool._navigation_session_key("default", "http://[::1]:3000/")
|
||||
assert key == "default::local"
|
||||
|
||||
def test_public_ip_literal_uses_bare_task_id(self, monkeypatch):
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
|
||||
key = browser_tool._navigation_session_key("default", "https://8.8.8.8/")
|
||||
assert key == "default"
|
||||
|
||||
def test_mdns_local_hostname_routes_to_sidecar(self, monkeypatch):
|
||||
"""``*.local`` mDNS / ``*.lan`` / ``*.internal`` hostnames route to sidecar."""
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
|
||||
for host in ("raspberrypi.local", "printer.lan", "db.internal"):
|
||||
key = browser_tool._navigation_session_key("default", f"http://{host}/")
|
||||
assert key == "default::local", f"host {host!r} did not route to sidecar"
|
||||
|
||||
def test_no_cloud_provider_stays_on_bare_task_id(self, monkeypatch):
|
||||
"""When cloud provider is not configured, no hybrid routing happens."""
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: None)
|
||||
key = browser_tool._navigation_session_key("default", "http://localhost:3000/")
|
||||
assert key == "default"
|
||||
|
||||
def test_camofox_mode_stays_on_bare_task_id(self, monkeypatch):
|
||||
"""Camofox is already local — no hybrid routing needed."""
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
|
||||
monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: True)
|
||||
key = browser_tool._navigation_session_key("default", "http://localhost:3000/")
|
||||
assert key == "default"
|
||||
|
||||
def test_cdp_override_stays_on_bare_task_id(self, monkeypatch):
|
||||
"""A user-supplied CDP endpoint owns the whole session — no hybrid."""
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
|
||||
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: "ws://localhost:9222")
|
||||
key = browser_tool._navigation_session_key("default", "http://localhost:3000/")
|
||||
assert key == "default"
|
||||
|
||||
def test_feature_flag_off_disables_hybrid_routing(self, monkeypatch):
|
||||
"""``auto_local_for_private_urls: false`` keeps private URLs on cloud."""
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
|
||||
monkeypatch.setattr(browser_tool, "_auto_local_for_private_urls", lambda: False)
|
||||
key = browser_tool._navigation_session_key("default", "http://localhost:3000/")
|
||||
assert key == "default"
|
||||
|
||||
def test_none_task_id_defaults(self, monkeypatch):
|
||||
"""``None`` task_id resolves to 'default'."""
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
|
||||
key = browser_tool._navigation_session_key(None, "http://localhost:3000/")
|
||||
assert key == "default::local"
|
||||
|
||||
|
||||
class TestSessionKeyHelpers:
|
||||
def test_is_local_sidecar_key(self):
|
||||
assert browser_tool._is_local_sidecar_key("default::local")
|
||||
assert browser_tool._is_local_sidecar_key("my_task::local")
|
||||
assert not browser_tool._is_local_sidecar_key("default")
|
||||
assert not browser_tool._is_local_sidecar_key("my_task")
|
||||
|
||||
def test_last_session_key_falls_back_to_task_id(self, monkeypatch):
|
||||
"""Without a recorded last-active key, returns the bare task_id."""
|
||||
monkeypatch.setattr(browser_tool, "_last_active_session_key", {})
|
||||
assert browser_tool._last_session_key("default") == "default"
|
||||
assert browser_tool._last_session_key("task-42") == "task-42"
|
||||
assert browser_tool._last_session_key(None) == "default"
|
||||
|
||||
def test_last_session_key_returns_recorded_key(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
browser_tool,
|
||||
"_last_active_session_key",
|
||||
{"default": "default::local", "task-42": "task-42"},
|
||||
)
|
||||
assert browser_tool._last_session_key("default") == "default::local"
|
||||
assert browser_tool._last_session_key("task-42") == "task-42"
|
||||
# Unknown task_id still falls back
|
||||
assert browser_tool._last_session_key("other") == "other"
|
||||
|
||||
|
||||
class TestHybridRoutingSessionCreation:
|
||||
"""_get_session_info must force a local session when the key carries ``::local``."""
|
||||
|
||||
def test_local_sidecar_key_skips_cloud_provider(self, monkeypatch):
|
||||
"""A ``::local``-suffixed key creates a local session even when cloud is set."""
|
||||
provider = Mock()
|
||||
provider.create_session.return_value = {
|
||||
"session_name": "should_not_be_used",
|
||||
"bb_session_id": "bb_xxx",
|
||||
"cdp_url": "wss://fake.browserbase.com/ws",
|
||||
}
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
|
||||
monkeypatch.setattr(browser_tool, "_ensure_cdp_supervisor", lambda t: None)
|
||||
|
||||
session = browser_tool._get_session_info("default::local")
|
||||
|
||||
assert provider.create_session.call_count == 0
|
||||
assert session["bb_session_id"] is None
|
||||
assert session["cdp_url"] is None
|
||||
assert session["features"]["local"] is True
|
||||
|
||||
def test_bare_task_id_with_cloud_provider_uses_cloud(self, monkeypatch):
|
||||
"""A bare task_id with cloud provider configured hits the cloud path."""
|
||||
provider = Mock()
|
||||
provider.create_session.return_value = {
|
||||
"session_name": "cloud-sess",
|
||||
"bb_session_id": "bb_123",
|
||||
"cdp_url": "wss://real.browserbase.com/ws",
|
||||
}
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
|
||||
monkeypatch.setattr(browser_tool, "_ensure_cdp_supervisor", lambda t: None)
|
||||
monkeypatch.setattr(browser_tool, "_resolve_cdp_override", lambda u: u)
|
||||
|
||||
session = browser_tool._get_session_info("default")
|
||||
|
||||
assert provider.create_session.call_count == 1
|
||||
assert session["bb_session_id"] == "bb_123"
|
||||
|
||||
|
||||
class TestCleanupHybridSessions:
|
||||
"""cleanup_browser(bare_task_id) must reap both cloud + local sidecar sessions."""
|
||||
|
||||
def test_cleanup_reaps_both_primary_and_sidecar(self, monkeypatch):
|
||||
"""Given a bare task_id with both sessions alive, both get cleaned."""
|
||||
reaped = []
|
||||
|
||||
def _fake_cleanup_one(key):
|
||||
reaped.append(key)
|
||||
|
||||
monkeypatch.setattr(browser_tool, "_cleanup_single_browser_session", _fake_cleanup_one)
|
||||
monkeypatch.setattr(
|
||||
browser_tool,
|
||||
"_active_sessions",
|
||||
{
|
||||
"default": {"session_name": "cloud_sess"},
|
||||
"default::local": {"session_name": "local_sess"},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
browser_tool, "_last_active_session_key", {"default": "default::local"}
|
||||
)
|
||||
|
||||
browser_tool.cleanup_browser("default")
|
||||
|
||||
assert set(reaped) == {"default", "default::local"}
|
||||
# last-active pointer dropped
|
||||
assert "default" not in browser_tool._last_active_session_key
|
||||
|
||||
def test_cleanup_reaps_only_primary_when_no_sidecar(self, monkeypatch):
|
||||
"""When no sidecar exists, only the primary is reaped."""
|
||||
reaped = []
|
||||
|
||||
def _fake_cleanup_one(key):
|
||||
reaped.append(key)
|
||||
|
||||
monkeypatch.setattr(browser_tool, "_cleanup_single_browser_session", _fake_cleanup_one)
|
||||
monkeypatch.setattr(
|
||||
browser_tool,
|
||||
"_active_sessions",
|
||||
{"default": {"session_name": "cloud_sess"}},
|
||||
)
|
||||
|
||||
browser_tool.cleanup_browser("default")
|
||||
|
||||
assert reaped == ["default"]
|
||||
|
||||
def test_cleanup_sidecar_directly_keeps_primary(self, monkeypatch):
|
||||
"""Calling cleanup with a ``::local`` key reaps only the sidecar."""
|
||||
reaped = []
|
||||
|
||||
def _fake_cleanup_one(key):
|
||||
reaped.append(key)
|
||||
|
||||
monkeypatch.setattr(browser_tool, "_cleanup_single_browser_session", _fake_cleanup_one)
|
||||
monkeypatch.setattr(
|
||||
browser_tool,
|
||||
"_active_sessions",
|
||||
{
|
||||
"default": {"session_name": "cloud_sess"},
|
||||
"default::local": {"session_name": "local_sess"},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
browser_tool, "_last_active_session_key", {"default": "default::local"}
|
||||
)
|
||||
|
||||
browser_tool.cleanup_browser("default::local")
|
||||
|
||||
assert reaped == ["default::local"]
|
||||
# Last-active pointer NOT dropped (primary task is still alive)
|
||||
assert browser_tool._last_active_session_key.get("default") == "default::local"
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Tests for credential_pool .env fallback and auth credential_pool lookup.
|
||||
|
||||
Covers the fix from #15914 / PR #15920:
|
||||
- _seed_from_env reads API keys from ~/.hermes/.env when not in os.environ
|
||||
- _resolve_api_key_provider_secret falls back to credential_pool when env vars are empty
|
||||
- env vars take priority over .env file (handled by get_env_value itself)
|
||||
- env vars take priority over credential pool (fallback only kicks in when env is empty)
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_pconfig(provider_id="deepseek", env_vars=None):
|
||||
"""Create a minimal ProviderConfig for testing.
|
||||
|
||||
Default provider_id is 'deepseek' because it's a real api_key provider
|
||||
in PROVIDER_REGISTRY (needed for _seed_from_env's generic path).
|
||||
"""
|
||||
from hermes_cli.auth import ProviderConfig
|
||||
return ProviderConfig(
|
||||
id=provider_id,
|
||||
name=provider_id.title(),
|
||||
auth_type="api_key",
|
||||
api_key_env_vars=tuple(env_vars or [f"{provider_id.upper()}_API_KEY"]),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_hermes_home(tmp_path, monkeypatch):
|
||||
"""Point HERMES_HOME at a temp dir and clear known API key env vars.
|
||||
|
||||
Also invalidates any cached get_env_value state by patching Path.home().
|
||||
"""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
# Clear all known API key env vars so get_env_value falls through to .env
|
||||
for key in [
|
||||
"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY",
|
||||
"ZAI_API_KEY", "DEEPSEEK_API_KEY", "ANTHROPIC_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_BASE_URL",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return home
|
||||
|
||||
|
||||
def _write_env_file(home: Path, **kwargs) -> None:
|
||||
"""Write key=value pairs to ~/.hermes/.env."""
|
||||
lines = [f"{k}={v}" for k, v in kwargs.items()]
|
||||
(home / ".env").write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
class TestCredentialPoolSeedsFromDotEnv:
|
||||
"""_seed_from_env must read keys from ~/.hermes/.env, not just os.environ.
|
||||
|
||||
This is the load-bearing behaviour for the fix: when a user adds a key to
|
||||
.env mid-session or via a non-CLI entry point that doesn't run
|
||||
load_hermes_dotenv, the credential pool must still discover it.
|
||||
"""
|
||||
|
||||
def test_deepseek_key_from_dotenv_only(self, isolated_hermes_home):
|
||||
"""Key in .env but not os.environ → _seed_from_env adds a pool entry."""
|
||||
_write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-only-12345")
|
||||
assert "DEEPSEEK_API_KEY" not in os.environ
|
||||
|
||||
from agent.credential_pool import _seed_from_env
|
||||
entries = []
|
||||
changed, active_sources = _seed_from_env("deepseek", entries)
|
||||
|
||||
assert changed is True
|
||||
assert "env:DEEPSEEK_API_KEY" in active_sources
|
||||
assert any(
|
||||
e.access_token == "sk-dotenv-only-12345"
|
||||
and e.source == "env:DEEPSEEK_API_KEY"
|
||||
for e in entries
|
||||
), f"Expected seeded entry with dotenv key, got: {[(e.source, e.access_token) for e in entries]}"
|
||||
|
||||
def test_openrouter_key_from_dotenv_only(self, isolated_hermes_home):
|
||||
"""OpenRouter path has its own branch — verify it also reads .env."""
|
||||
_write_env_file(isolated_hermes_home, OPENROUTER_API_KEY="sk-or-dotenv-abc")
|
||||
assert "OPENROUTER_API_KEY" not in os.environ
|
||||
|
||||
from agent.credential_pool import _seed_from_env
|
||||
entries = []
|
||||
changed, active_sources = _seed_from_env("openrouter", entries)
|
||||
|
||||
assert changed is True
|
||||
assert "env:OPENROUTER_API_KEY" in active_sources
|
||||
assert any(
|
||||
e.access_token == "sk-or-dotenv-abc" for e in entries
|
||||
)
|
||||
|
||||
def test_empty_dotenv_no_entries(self, isolated_hermes_home):
|
||||
"""No .env file, no env vars → no entries seeded (and no crash)."""
|
||||
from agent.credential_pool import _seed_from_env
|
||||
entries = []
|
||||
changed, active_sources = _seed_from_env("deepseek", entries)
|
||||
assert changed is False
|
||||
assert active_sources == set()
|
||||
assert entries == []
|
||||
|
||||
def test_os_environ_still_wins_over_dotenv(self, isolated_hermes_home, monkeypatch):
|
||||
"""get_env_value checks os.environ first — verify seeding picks that up."""
|
||||
_write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-stale")
|
||||
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-env-fresh-xyz")
|
||||
|
||||
from agent.credential_pool import _seed_from_env
|
||||
entries = []
|
||||
changed, _ = _seed_from_env("deepseek", entries)
|
||||
|
||||
assert changed is True
|
||||
seeded = [e for e in entries if e.source == "env:DEEPSEEK_API_KEY"]
|
||||
assert len(seeded) == 1
|
||||
assert seeded[0].access_token == "sk-env-fresh-xyz"
|
||||
|
||||
|
||||
class TestAuthResolvesFromDotEnv:
|
||||
"""_resolve_api_key_provider_secret must also read from ~/.hermes/.env."""
|
||||
|
||||
def test_key_from_dotenv_only(self, isolated_hermes_home):
|
||||
"""Key in .env but not os.environ → _resolve returns it with the env var source."""
|
||||
_write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-resolve-789")
|
||||
assert "DEEPSEEK_API_KEY" not in os.environ
|
||||
|
||||
from hermes_cli.auth import _resolve_api_key_provider_secret
|
||||
key, source = _resolve_api_key_provider_secret(
|
||||
provider_id="deepseek",
|
||||
pconfig=_make_pconfig(),
|
||||
)
|
||||
assert key == "sk-dotenv-resolve-789"
|
||||
assert source == "DEEPSEEK_API_KEY"
|
||||
|
||||
|
||||
class TestAuthCredentialPoolFallback:
|
||||
"""_resolve_api_key_provider_secret falls back to credential pool when env + dotenv are empty."""
|
||||
|
||||
def test_credential_pool_fallback_structure(self, isolated_hermes_home):
|
||||
"""Empty env + empty .env → auth falls back to credential pool."""
|
||||
mock_entry = MagicMock()
|
||||
mock_entry.access_token = "test-pool-key-12345"
|
||||
mock_entry.runtime_api_key = ""
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.has_credentials.return_value = True
|
||||
mock_pool.peek.return_value = mock_entry
|
||||
|
||||
from hermes_cli.auth import _resolve_api_key_provider_secret
|
||||
with patch("agent.credential_pool.load_pool", return_value=mock_pool):
|
||||
key, source = _resolve_api_key_provider_secret(
|
||||
provider_id="deepseek",
|
||||
pconfig=_make_pconfig(),
|
||||
)
|
||||
assert "test-pool-key-12345" in key
|
||||
assert "credential_pool" in source
|
||||
|
||||
def test_credential_pool_empty_returns_empty(self, isolated_hermes_home):
|
||||
"""Empty env + empty .env + empty pool → empty string."""
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.has_credentials.return_value = False
|
||||
|
||||
from hermes_cli.auth import _resolve_api_key_provider_secret
|
||||
with patch("agent.credential_pool.load_pool", return_value=mock_pool):
|
||||
key, source = _resolve_api_key_provider_secret(
|
||||
provider_id="deepseek",
|
||||
pconfig=_make_pconfig(),
|
||||
)
|
||||
assert key == ""
|
||||
|
||||
def test_env_var_takes_priority_over_pool(self, isolated_hermes_home, monkeypatch):
|
||||
"""os.environ key wins — credential pool is NEVER consulted."""
|
||||
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-env-key-first-abc123")
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.has_credentials.return_value = True
|
||||
|
||||
from hermes_cli.auth import _resolve_api_key_provider_secret
|
||||
with patch("agent.credential_pool.load_pool", return_value=mock_pool) as mp:
|
||||
key, source = _resolve_api_key_provider_secret(
|
||||
provider_id="deepseek",
|
||||
pconfig=_make_pconfig(),
|
||||
)
|
||||
assert key == "sk-env-key-first-abc123"
|
||||
assert source == "DEEPSEEK_API_KEY"
|
||||
# Pool should not even have been loaded — env var satisfied the request first
|
||||
mp.assert_not_called()
|
||||
|
||||
def test_dotenv_takes_priority_over_pool(self, isolated_hermes_home):
|
||||
"""Key in .env beats credential pool — pool only fires when both env sources are empty."""
|
||||
_write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-priority-xyz")
|
||||
assert "DEEPSEEK_API_KEY" not in os.environ
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.has_credentials.return_value = True
|
||||
|
||||
from hermes_cli.auth import _resolve_api_key_provider_secret
|
||||
with patch("agent.credential_pool.load_pool", return_value=mock_pool) as mp:
|
||||
key, source = _resolve_api_key_provider_secret(
|
||||
provider_id="deepseek",
|
||||
pconfig=_make_pconfig(),
|
||||
)
|
||||
assert key == "sk-dotenv-priority-xyz"
|
||||
assert source == "DEEPSEEK_API_KEY"
|
||||
mp.assert_not_called()
|
||||
@@ -810,6 +810,44 @@ class TestParseTargetRefE164:
|
||||
assert _parse_target_ref("matrix", "+15551234567")[2] is False
|
||||
|
||||
|
||||
class TestParseTargetRefSlack:
|
||||
"""_parse_target_ref recognizes Slack channel/user IDs as explicit."""
|
||||
|
||||
def test_public_channel_id_is_explicit(self):
|
||||
chat_id, thread_id, is_explicit = _parse_target_ref("slack", "C0B0QV5434G")
|
||||
assert chat_id == "C0B0QV5434G"
|
||||
assert thread_id is None
|
||||
assert is_explicit is True
|
||||
|
||||
def test_private_channel_id_is_explicit(self):
|
||||
assert _parse_target_ref("slack", "G123ABCDEF")[2] is True
|
||||
|
||||
def test_dm_id_is_explicit(self):
|
||||
assert _parse_target_ref("slack", "D123ABCDEF")[2] is True
|
||||
|
||||
def test_user_id_is_not_explicit(self):
|
||||
"""Slack user IDs (U...) and workspace IDs (W...) are NOT explicit send
|
||||
targets. chat.postMessage rejects them — a DM must be opened first via
|
||||
conversations.open to obtain a D... conversation ID.
|
||||
"""
|
||||
assert _parse_target_ref("slack", "U123ABCDEF")[2] is False
|
||||
assert _parse_target_ref("slack", "W123ABCDEF")[2] is False
|
||||
|
||||
def test_whitespace_is_stripped(self):
|
||||
chat_id, _, is_explicit = _parse_target_ref("slack", " C0B0QV5434G ")
|
||||
assert chat_id == "C0B0QV5434G"
|
||||
assert is_explicit is True
|
||||
|
||||
def test_lowercase_or_short_id_is_not_explicit(self):
|
||||
assert _parse_target_ref("slack", "c0b0qv5434g")[2] is False
|
||||
assert _parse_target_ref("slack", "C123")[2] is False
|
||||
assert _parse_target_ref("slack", "X0B0QV5434G")[2] is False
|
||||
|
||||
def test_slack_id_not_explicit_for_other_platforms(self):
|
||||
assert _parse_target_ref("discord", "C0B0QV5434G")[2] is False
|
||||
assert _parse_target_ref("telegram", "C0B0QV5434G")[2] is False
|
||||
|
||||
|
||||
class TestSendDiscordThreadId:
|
||||
"""_send_discord uses thread_id when provided."""
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Regression tests for the shared-container task_id mapping.
|
||||
|
||||
The top-level agent and all delegate_task subagents share a single
|
||||
terminal sandbox keyed by ``"default"``. ``_resolve_container_task_id``
|
||||
is the sole gatekeeper for which tool-call task_ids go to the shared
|
||||
container vs. get their own isolated sandbox. RL / benchmark
|
||||
environments opt in to isolation by calling
|
||||
``register_task_env_overrides(task_id, {...})`` before the agent loop;
|
||||
every other task_id collapses back to ``"default"``.
|
||||
|
||||
If you change the collapse logic, update both the helper and these
|
||||
tests -- see `hermes-agent-dev` skill, "Why do subagents get their own
|
||||
containers?" section, and the Container lifecycle paragraph under
|
||||
Docker Backend in ``website/docs/user-guide/configuration.md``.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import terminal_tool
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_overrides():
|
||||
"""Ensure no stray overrides from other tests leak in."""
|
||||
before = dict(terminal_tool._task_env_overrides)
|
||||
terminal_tool._task_env_overrides.clear()
|
||||
yield
|
||||
terminal_tool._task_env_overrides.clear()
|
||||
terminal_tool._task_env_overrides.update(before)
|
||||
|
||||
|
||||
def test_none_task_id_maps_to_default():
|
||||
assert terminal_tool._resolve_container_task_id(None) == "default"
|
||||
|
||||
|
||||
def test_empty_task_id_maps_to_default():
|
||||
assert terminal_tool._resolve_container_task_id("") == "default"
|
||||
|
||||
|
||||
def test_literal_default_stays_default():
|
||||
assert terminal_tool._resolve_container_task_id("default") == "default"
|
||||
|
||||
|
||||
def test_subagent_task_id_collapses_to_default():
|
||||
# delegate_task constructs IDs like "subagent-<N>-<uuid_hex>"; these
|
||||
# should share the parent's container, not spin up their own.
|
||||
assert terminal_tool._resolve_container_task_id("subagent-0-deadbeef") == "default"
|
||||
assert terminal_tool._resolve_container_task_id("subagent-42-cafef00d") == "default"
|
||||
|
||||
|
||||
def test_arbitrary_session_id_collapses_to_default():
|
||||
# Session UUIDs or anything else without an override still collapse.
|
||||
assert terminal_tool._resolve_container_task_id("sess-123e4567-e89b-12d3") == "default"
|
||||
|
||||
|
||||
def test_rl_task_with_override_keeps_its_own_id():
|
||||
# RL / benchmark pattern: register a per-task image, then the task_id
|
||||
# must survive ``_resolve_container_task_id`` so the rollout lands in
|
||||
# its own sandbox.
|
||||
terminal_tool.register_task_env_overrides(
|
||||
"tb2-task-fix-git", {"docker_image": "tb2:fix-git", "cwd": "/app"}
|
||||
)
|
||||
try:
|
||||
assert (
|
||||
terminal_tool._resolve_container_task_id("tb2-task-fix-git")
|
||||
== "tb2-task-fix-git"
|
||||
)
|
||||
finally:
|
||||
terminal_tool.clear_task_env_overrides("tb2-task-fix-git")
|
||||
|
||||
|
||||
def test_cleared_override_collapses_again():
|
||||
terminal_tool.register_task_env_overrides("tb2-x", {"docker_image": "x:y"})
|
||||
assert terminal_tool._resolve_container_task_id("tb2-x") == "tb2-x"
|
||||
terminal_tool.clear_task_env_overrides("tb2-x")
|
||||
assert terminal_tool._resolve_container_task_id("tb2-x") == "default"
|
||||
|
||||
|
||||
def test_get_active_env_reads_shared_container_from_subagent_id():
|
||||
"""``get_active_env`` must see the shared ``"default"`` sandbox when
|
||||
called with a subagent's task_id, so the agent loop's turn-budget
|
||||
enforcement reads the real env (not None) during delegation."""
|
||||
sentinel = object()
|
||||
terminal_tool._active_environments["default"] = sentinel
|
||||
try:
|
||||
assert terminal_tool.get_active_env("subagent-7-cafe") is sentinel
|
||||
assert terminal_tool.get_active_env(None) is sentinel
|
||||
assert terminal_tool.get_active_env("default") is sentinel
|
||||
finally:
|
||||
terminal_tool._active_environments.pop("default", None)
|
||||
|
||||
|
||||
def test_get_active_env_honours_rl_override():
|
||||
rl_env = object()
|
||||
default_env = object()
|
||||
terminal_tool._active_environments["default"] = default_env
|
||||
terminal_tool._active_environments["rl-42"] = rl_env
|
||||
terminal_tool.register_task_env_overrides("rl-42", {"docker_image": "x"})
|
||||
try:
|
||||
# With an override registered, lookup returns the task's own env,
|
||||
# not the shared "default" one.
|
||||
assert terminal_tool.get_active_env("rl-42") is rl_env
|
||||
finally:
|
||||
terminal_tool.clear_task_env_overrides("rl-42")
|
||||
terminal_tool._active_environments.pop("default", None)
|
||||
terminal_tool._active_environments.pop("rl-42", None)
|
||||
+280
-49
@@ -483,6 +483,147 @@ def _is_local_backend() -> bool:
|
||||
return _is_camofox_mode() or _get_cloud_provider() is None
|
||||
|
||||
|
||||
_auto_local_for_private_urls_resolved = False
|
||||
_cached_auto_local_for_private_urls: bool = True
|
||||
|
||||
|
||||
def _auto_local_for_private_urls() -> bool:
|
||||
"""Return whether a cloud-configured install should auto-spawn a local
|
||||
Chromium for LAN/localhost URLs.
|
||||
|
||||
Reads ``browser.auto_local_for_private_urls`` once (default ``True``) and
|
||||
caches it for the process lifetime. When enabled, ``browser_navigate``
|
||||
routes URLs whose host resolves to a private/loopback/LAN address to a
|
||||
local headless Chromium sidecar even when a cloud provider (Browserbase
|
||||
/ Browser-Use / Firecrawl) is configured globally. Public URLs continue
|
||||
to use the cloud provider in the same conversation.
|
||||
"""
|
||||
global _auto_local_for_private_urls_resolved, _cached_auto_local_for_private_urls
|
||||
if _auto_local_for_private_urls_resolved:
|
||||
return _cached_auto_local_for_private_urls
|
||||
|
||||
_auto_local_for_private_urls_resolved = True
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
cfg = read_raw_config()
|
||||
browser_cfg = cfg.get("browser", {})
|
||||
if isinstance(browser_cfg, dict) and "auto_local_for_private_urls" in browser_cfg:
|
||||
_cached_auto_local_for_private_urls = bool(
|
||||
browser_cfg.get("auto_local_for_private_urls")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Could not read auto_local_for_private_urls from config: %s", e)
|
||||
return _cached_auto_local_for_private_urls
|
||||
|
||||
|
||||
def _url_is_private(url: str) -> bool:
|
||||
"""Return True when the URL's host resolves to a private/LAN/loopback address.
|
||||
|
||||
Reuses ``tools.url_safety.is_safe_url`` as the oracle — if the SSRF check
|
||||
would reject the URL, we treat it as "private" for routing purposes. DNS
|
||||
resolution failures are treated as NOT private (fall through to whatever
|
||||
backend is configured, which will surface the DNS error naturally).
|
||||
"""
|
||||
try:
|
||||
from tools.url_safety import is_safe_url
|
||||
# is_safe_url returns False for private/loopback/link-local/CGNAT AND
|
||||
# for DNS failures. We only want the private-network case here, so
|
||||
# we parse + check the host shape as a DNS-failure sieve first.
|
||||
from urllib.parse import urlparse
|
||||
import ipaddress
|
||||
import socket
|
||||
parsed = urlparse(url)
|
||||
hostname = (parsed.hostname or "").strip().lower().rstrip(".")
|
||||
if not hostname:
|
||||
return False
|
||||
# Literal IP → check directly
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
return (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip in ipaddress.ip_network("100.64.0.0/10")
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
# Hostname — must resolve to confirm it's private (bare "localhost"
|
||||
# resolves to 127.0.0.1 via /etc/hosts). Short-circuit on obvious
|
||||
# names to avoid a DNS hop.
|
||||
if hostname in ("localhost",) or hostname.endswith(".localhost"):
|
||||
return True
|
||||
if hostname.endswith(".local") or hostname.endswith(".lan") or hostname.endswith(".internal"):
|
||||
return True
|
||||
try:
|
||||
addr_info = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||
except socket.gaierror:
|
||||
return False # DNS fail → not private, let the normal path fail
|
||||
for _, _, _, _, sockaddr in addr_info:
|
||||
try:
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
except ValueError:
|
||||
continue
|
||||
if (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip in ipaddress.ip_network("100.64.0.0/10")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.debug("URL-privacy check failed for %s: %s", url, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _navigation_session_key(task_id: str, url: str) -> str:
|
||||
"""Pick the session key that should handle ``url`` for ``task_id``.
|
||||
|
||||
Returns the bare task_id unless ALL of these are true:
|
||||
1. A cloud provider is configured (``_get_cloud_provider()`` is not None).
|
||||
2. Auto-local routing is enabled (``browser.auto_local_for_private_urls``,
|
||||
default True).
|
||||
3. The URL resolves to a private/LAN/loopback address.
|
||||
4. A CDP override is not active (that path owns the whole session).
|
||||
5. Camofox mode is not active (Camofox is already local-only).
|
||||
|
||||
When all are true, returns ``f"{task_id}::local"`` so the hybrid-routing
|
||||
path spawns a local Chromium sidecar while the cloud session (if any)
|
||||
continues to serve public URLs.
|
||||
"""
|
||||
if task_id is None:
|
||||
task_id = "default"
|
||||
if _get_cdp_override():
|
||||
return task_id
|
||||
if _is_camofox_mode():
|
||||
return task_id
|
||||
if _get_cloud_provider() is None:
|
||||
return task_id
|
||||
if not _auto_local_for_private_urls():
|
||||
return task_id
|
||||
if not _url_is_private(url):
|
||||
return task_id
|
||||
return f"{task_id}{_LOCAL_SUFFIX}"
|
||||
|
||||
|
||||
def _is_local_sidecar_key(session_key: str) -> bool:
|
||||
"""Return True when ``session_key`` is a hybrid-routing local sidecar."""
|
||||
return session_key.endswith(_LOCAL_SUFFIX)
|
||||
|
||||
|
||||
def _last_session_key(task_id: str) -> str:
|
||||
"""Return the session key to use for a non-nav browser tool call.
|
||||
|
||||
If a previous ``browser_navigate`` on this task_id set a last-active key,
|
||||
use it so snapshot/click/fill/etc. hit the same session. Otherwise fall
|
||||
back to the bare task_id (matches original behavior for tasks that never
|
||||
triggered hybrid routing).
|
||||
"""
|
||||
if task_id is None:
|
||||
task_id = "default"
|
||||
return _last_active_session_key.get(task_id, task_id)
|
||||
|
||||
|
||||
def _allow_private_urls() -> bool:
|
||||
"""Return whether the browser is allowed to navigate to private/internal addresses.
|
||||
|
||||
@@ -521,10 +662,25 @@ def _socket_safe_tmpdir() -> str:
|
||||
return tempfile.gettempdir()
|
||||
|
||||
|
||||
# Track active sessions per task
|
||||
# Track active sessions per "session key".
|
||||
#
|
||||
# A "session key" is either the bare task_id (cloud/default path) OR a composite
|
||||
# like f"{task_id}::local" when the hybrid-routing feature spawns a local sidecar
|
||||
# browser for a LAN/localhost URL while a cloud provider is configured globally.
|
||||
# Both forms flow through the same _active_sessions / _run_browser_command /
|
||||
# cleanup_browser code paths — the key is opaque to those internals.
|
||||
#
|
||||
# Stores: session_name (always), bb_session_id + cdp_url (cloud mode only)
|
||||
_active_sessions: Dict[str, Dict[str, str]] = {} # task_id -> {session_name, ...}
|
||||
_recording_sessions: set = set() # task_ids with active recordings
|
||||
_active_sessions: Dict[str, Dict[str, str]] = {} # session_key -> {session_name, ...}
|
||||
_recording_sessions: set = set() # session_keys with active recordings
|
||||
|
||||
# Tracks the most recent session_key used per task_id. Set by browser_navigate()
|
||||
# after it chooses a backend for a URL; read by every non-nav browser tool
|
||||
# (snapshot/click/fill/eval/...) so they target the session that served the last
|
||||
# navigation. Without this, a task that navigated to localhost on the local
|
||||
# sidecar would fall back to the cloud session on its next snapshot call.
|
||||
_last_active_session_key: Dict[str, str] = {} # task_id -> session_key
|
||||
_LOCAL_SUFFIX = "::local"
|
||||
|
||||
# Flag to track if cleanup has been done
|
||||
_cleanup_done = False
|
||||
@@ -1014,37 +1170,48 @@ def _create_cdp_session(task_id: str, cdp_url: str) -> Dict[str, str]:
|
||||
|
||||
def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]:
|
||||
"""
|
||||
Get or create session info for the given task.
|
||||
|
||||
Get or create session info for the given session key.
|
||||
|
||||
In cloud mode, creates a Browserbase session with proxies enabled.
|
||||
In local mode, generates a session name for agent-browser --session.
|
||||
Also starts the inactivity cleanup thread and updates activity tracking.
|
||||
Thread-safe: multiple subagents can call this concurrently.
|
||||
|
||||
|
||||
Args:
|
||||
task_id: Unique identifier for the task
|
||||
|
||||
task_id: Session key. Normally the task_id as-is, but may carry the
|
||||
``::local`` suffix for the hybrid-routing local sidecar — in that
|
||||
case the cloud provider is skipped even when one is configured,
|
||||
and a local Chromium session is created instead.
|
||||
|
||||
Returns:
|
||||
Dict with session_name (always), bb_session_id + cdp_url (cloud only)
|
||||
"""
|
||||
if task_id is None:
|
||||
task_id = "default"
|
||||
|
||||
|
||||
# Start the cleanup thread if not running (handles inactivity timeouts)
|
||||
_start_browser_cleanup_thread()
|
||||
|
||||
|
||||
# Update activity timestamp for this session
|
||||
_update_session_activity(task_id)
|
||||
|
||||
|
||||
with _cleanup_lock:
|
||||
# Check if we already have a session for this task
|
||||
if task_id in _active_sessions:
|
||||
return _active_sessions[task_id]
|
||||
|
||||
|
||||
# Hybrid routing: session keys ending with ``::local`` force a local
|
||||
# Chromium regardless of the globally-configured cloud provider. Public
|
||||
# URLs in the same conversation continue to use the cloud session under
|
||||
# the bare task_id key.
|
||||
force_local = _is_local_sidecar_key(task_id)
|
||||
|
||||
# Create session outside the lock (network call in cloud mode)
|
||||
cdp_override = _get_cdp_override()
|
||||
if cdp_override:
|
||||
if cdp_override and not force_local:
|
||||
session_info = _create_cdp_session(task_id, cdp_override)
|
||||
elif force_local:
|
||||
session_info = _create_local_session(task_id)
|
||||
else:
|
||||
provider = _get_cloud_provider()
|
||||
if provider is None:
|
||||
@@ -1081,7 +1248,7 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]:
|
||||
session_info["fallback_from_cloud"] = True
|
||||
session_info["fallback_reason"] = str(e)
|
||||
session_info["fallback_provider"] = provider_name
|
||||
|
||||
|
||||
with _cleanup_lock:
|
||||
# Double-check: another thread may have created a session while we
|
||||
# were doing the network call. Use the existing one to avoid leaking
|
||||
@@ -1093,7 +1260,9 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]:
|
||||
# Lazy-start the CDP supervisor now that the session exists (if the
|
||||
# backend surfaces a CDP URL via override or session_info["cdp_url"]).
|
||||
# Idempotent; swallows errors. See _ensure_cdp_supervisor for details.
|
||||
_ensure_cdp_supervisor(task_id)
|
||||
# Skip for local sidecars — they have no CDP URL.
|
||||
if not force_local:
|
||||
_ensure_cdp_supervisor(task_id)
|
||||
|
||||
return session_info
|
||||
|
||||
@@ -1521,9 +1690,21 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
|
||||
# SSRF protection — block private/internal addresses before navigating.
|
||||
# Skipped for local backends (Camofox, headless Chromium without a cloud
|
||||
# provider) because the agent already has full local network access via
|
||||
# the terminal tool. Can also be opted out for cloud mode via
|
||||
# ``browser.allow_private_urls`` in config.
|
||||
if not _is_local_backend() and not _allow_private_urls() and not _is_safe_url(url):
|
||||
# the terminal tool. Also skipped when hybrid routing will auto-spawn a
|
||||
# local Chromium sidecar for this URL (cloud provider configured +
|
||||
# private URL + ``browser.auto_local_for_private_urls`` enabled) — the
|
||||
# cloud provider never sees the URL in that case. Can also be opted
|
||||
# out globally via ``browser.allow_private_urls`` in config.
|
||||
effective_task_id = task_id or "default"
|
||||
nav_session_key = _navigation_session_key(effective_task_id, url)
|
||||
auto_local_this_nav = _is_local_sidecar_key(nav_session_key)
|
||||
|
||||
if (
|
||||
not _is_local_backend()
|
||||
and not auto_local_this_nav
|
||||
and not _allow_private_urls()
|
||||
and not _is_safe_url(url)
|
||||
):
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": "Blocked: URL targets a private or internal address",
|
||||
@@ -1543,19 +1724,31 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
|
||||
from tools.browser_camofox import camofox_navigate
|
||||
return camofox_navigate(url, task_id)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
|
||||
if auto_local_this_nav:
|
||||
logger.info(
|
||||
"browser_navigate: auto-routing %s to local Chromium sidecar "
|
||||
"(cloud provider %s stays on cloud for public URLs; "
|
||||
"set browser.auto_local_for_private_urls: false to disable)",
|
||||
url,
|
||||
type(_get_cloud_provider()).__name__ if _get_cloud_provider() else "none",
|
||||
)
|
||||
|
||||
# Get session info to check if this is a new session
|
||||
# (will create one with features logged if not exists)
|
||||
session_info = _get_session_info(effective_task_id)
|
||||
session_info = _get_session_info(nav_session_key)
|
||||
is_first_nav = session_info.get("_first_nav", True)
|
||||
|
||||
|
||||
# Auto-start recording if configured and this is first navigation
|
||||
if is_first_nav:
|
||||
session_info["_first_nav"] = False
|
||||
_maybe_start_recording(effective_task_id)
|
||||
_maybe_start_recording(nav_session_key)
|
||||
|
||||
result = _run_browser_command(effective_task_id, "open", [url], timeout=max(_get_command_timeout(), 60))
|
||||
result = _run_browser_command(nav_session_key, "open", [url], timeout=max(_get_command_timeout(), 60))
|
||||
|
||||
# Remember which session served this nav so snapshot/click/fill/...
|
||||
# on the same task_id hit it (critical when hybrid routing has both a
|
||||
# cloud session and a local sidecar alive concurrently).
|
||||
_last_active_session_key[effective_task_id] = nav_session_key
|
||||
|
||||
if result.get("success"):
|
||||
data = result.get("data", {})
|
||||
@@ -1565,10 +1758,17 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
|
||||
# Post-redirect SSRF check — if the browser followed a redirect to a
|
||||
# private/internal address, block the result so the model can't read
|
||||
# internal content via subsequent browser_snapshot calls.
|
||||
# Skipped for local backends (same rationale as the pre-nav check).
|
||||
if not _is_local_backend() and not _allow_private_urls() and final_url and final_url != url and not _is_safe_url(final_url):
|
||||
# Skipped for local backends (same rationale as the pre-nav check),
|
||||
# and for the hybrid local sidecar (we're already on a local browser
|
||||
# hitting a private URL by design).
|
||||
if (
|
||||
not _is_local_backend()
|
||||
and not auto_local_this_nav
|
||||
and not _allow_private_urls()
|
||||
and final_url and final_url != url and not _is_safe_url(final_url)
|
||||
):
|
||||
# Navigate away to a blank page to prevent snapshot leaks
|
||||
_run_browser_command(effective_task_id, "open", ["about:blank"], timeout=10)
|
||||
_run_browser_command(nav_session_key, "open", ["about:blank"], timeout=10)
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": "Blocked: redirect landed on a private/internal address",
|
||||
@@ -1612,7 +1812,7 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
|
||||
# Auto-take a compact snapshot so the model can act immediately
|
||||
# without a separate browser_snapshot call.
|
||||
try:
|
||||
snap_result = _run_browser_command(effective_task_id, "snapshot", ["-c"])
|
||||
snap_result = _run_browser_command(nav_session_key, "snapshot", ["-c"])
|
||||
if snap_result.get("success"):
|
||||
snap_data = snap_result.get("data", {})
|
||||
snapshot_text = snap_data.get("snapshot", "")
|
||||
@@ -1652,7 +1852,7 @@ def browser_snapshot(
|
||||
from tools.browser_camofox import camofox_snapshot
|
||||
return camofox_snapshot(full, task_id, user_task)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
effective_task_id = _last_session_key(task_id or "default")
|
||||
|
||||
# Build command args based on full flag
|
||||
args = []
|
||||
@@ -1714,7 +1914,7 @@ def browser_click(ref: str, task_id: Optional[str] = None) -> str:
|
||||
from tools.browser_camofox import camofox_click
|
||||
return camofox_click(ref, task_id)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
effective_task_id = _last_session_key(task_id or "default")
|
||||
|
||||
# Ensure ref starts with @
|
||||
if not ref.startswith("@"):
|
||||
@@ -1750,7 +1950,7 @@ def browser_type(ref: str, text: str, task_id: Optional[str] = None) -> str:
|
||||
from tools.browser_camofox import camofox_type
|
||||
return camofox_type(ref, text, task_id)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
effective_task_id = _last_session_key(task_id or "default")
|
||||
|
||||
# Ensure ref starts with @
|
||||
if not ref.startswith("@"):
|
||||
@@ -1804,7 +2004,7 @@ def browser_scroll(direction: str, task_id: Optional[str] = None) -> str:
|
||||
result = camofox_scroll(direction, task_id)
|
||||
return result
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
effective_task_id = _last_session_key(task_id or "default")
|
||||
|
||||
result = _run_browser_command(effective_task_id, "scroll", [direction, str(_SCROLL_PIXELS)])
|
||||
if not result.get("success"):
|
||||
@@ -1833,7 +2033,7 @@ def browser_back(task_id: Optional[str] = None) -> str:
|
||||
from tools.browser_camofox import camofox_back
|
||||
return camofox_back(task_id)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
effective_task_id = _last_session_key(task_id or "default")
|
||||
result = _run_browser_command(effective_task_id, "back", [])
|
||||
|
||||
if result.get("success"):
|
||||
@@ -1864,7 +2064,7 @@ def browser_press(key: str, task_id: Optional[str] = None) -> str:
|
||||
from tools.browser_camofox import camofox_press
|
||||
return camofox_press(key, task_id)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
effective_task_id = _last_session_key(task_id or "default")
|
||||
result = _run_browser_command(effective_task_id, "press", [key])
|
||||
|
||||
if result.get("success"):
|
||||
@@ -1906,7 +2106,7 @@ def browser_console(clear: bool = False, expression: Optional[str] = None, task_
|
||||
from tools.browser_camofox import camofox_console
|
||||
return camofox_console(clear, task_id)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
effective_task_id = _last_session_key(task_id or "default")
|
||||
|
||||
console_args = ["--clear"] if clear else []
|
||||
error_args = ["--clear"] if clear else []
|
||||
@@ -1945,7 +2145,7 @@ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str:
|
||||
if _is_camofox_mode():
|
||||
return _camofox_eval(expression, task_id)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
effective_task_id = _last_session_key(task_id or "default")
|
||||
result = _run_browser_command(effective_task_id, "eval", [expression])
|
||||
|
||||
if not result.get("success"):
|
||||
@@ -2077,7 +2277,7 @@ def browser_get_images(task_id: Optional[str] = None) -> str:
|
||||
from tools.browser_camofox import camofox_get_images
|
||||
return camofox_get_images(task_id)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
effective_task_id = _last_session_key(task_id or "default")
|
||||
|
||||
# Use eval to run JavaScript that extracts images
|
||||
js_code = """JSON.stringify(
|
||||
@@ -2147,7 +2347,7 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
|
||||
|
||||
import base64
|
||||
import uuid as uuid_mod
|
||||
effective_task_id = task_id or "default"
|
||||
effective_task_id = _last_session_key(task_id or "default")
|
||||
|
||||
# Save screenshot to persistent location so it can be shared with users
|
||||
from hermes_constants import get_hermes_dir
|
||||
@@ -2350,17 +2550,47 @@ def _cleanup_old_recordings(max_age_hours=72):
|
||||
|
||||
def cleanup_browser(task_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
Clean up browser session for a task.
|
||||
|
||||
Clean up browser session(s) for a task.
|
||||
|
||||
Called automatically when a task completes or when inactivity timeout is reached.
|
||||
Closes both the agent-browser/Browserbase session and Camofox sessions.
|
||||
|
||||
|
||||
When ``task_id`` is a bare task identifier (no ``::local`` suffix), reaps
|
||||
BOTH the cloud/primary session AND any hybrid-routing local sidecar that
|
||||
may have been spawned for LAN/localhost URLs in the same task. When
|
||||
``task_id`` already carries a ``::local`` suffix (called from the inactivity
|
||||
cleanup loop against a specific session key), reaps only that one.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier to clean up
|
||||
task_id: Task identifier (or explicit session key)
|
||||
"""
|
||||
if task_id is None:
|
||||
task_id = "default"
|
||||
|
||||
# Expand to the full set of session keys to reap. For a bare task_id
|
||||
# that includes the cloud/primary key + the local sidecar if one exists.
|
||||
if _is_local_sidecar_key(task_id):
|
||||
session_keys = [task_id]
|
||||
bare_task_id = task_id[: -len(_LOCAL_SUFFIX)]
|
||||
else:
|
||||
session_keys = [task_id]
|
||||
sidecar_key = f"{task_id}{_LOCAL_SUFFIX}"
|
||||
with _cleanup_lock:
|
||||
if sidecar_key in _active_sessions:
|
||||
session_keys.append(sidecar_key)
|
||||
bare_task_id = task_id
|
||||
|
||||
for session_key in session_keys:
|
||||
_cleanup_single_browser_session(session_key)
|
||||
|
||||
# Drop the last-active pointer only when the bare task is being cleaned
|
||||
# (i.e. not when we're only reaping a sidecar mid-task).
|
||||
if not _is_local_sidecar_key(task_id):
|
||||
_last_active_session_key.pop(bare_task_id, None)
|
||||
|
||||
|
||||
def _cleanup_single_browser_session(task_id: str) -> None:
|
||||
"""Internal: reap a single browser session by its exact session key."""
|
||||
# Stop the CDP supervisor for this task FIRST so we close our WebSocket
|
||||
# before the backend tears down the underlying CDP endpoint.
|
||||
_stop_cdp_supervisor(task_id)
|
||||
@@ -2379,32 +2609,33 @@ def cleanup_browser(task_id: Optional[str] = None) -> None:
|
||||
|
||||
logger.debug("cleanup_browser called for task_id: %s", task_id)
|
||||
logger.debug("Active sessions: %s", list(_active_sessions.keys()))
|
||||
|
||||
|
||||
# Check if session exists (under lock), but don't remove yet -
|
||||
# _run_browser_command needs it to build the close command.
|
||||
with _cleanup_lock:
|
||||
session_info = _active_sessions.get(task_id)
|
||||
|
||||
|
||||
if session_info:
|
||||
bb_session_id = session_info.get("bb_session_id", "unknown")
|
||||
logger.debug("Found session for task %s: bb_session_id=%s", task_id, bb_session_id)
|
||||
|
||||
|
||||
# Stop auto-recording before closing (saves the file)
|
||||
_maybe_stop_recording(task_id)
|
||||
|
||||
|
||||
# Try to close via agent-browser first (needs session in _active_sessions)
|
||||
try:
|
||||
_run_browser_command(task_id, "close", [], timeout=10)
|
||||
logger.debug("agent-browser close command completed for task %s", task_id)
|
||||
except Exception as e:
|
||||
logger.warning("agent-browser close failed for task %s: %s", task_id, e)
|
||||
|
||||
|
||||
# Now remove from tracking under lock
|
||||
with _cleanup_lock:
|
||||
_active_sessions.pop(task_id, None)
|
||||
_session_last_activity.pop(task_id, None)
|
||||
|
||||
# Cloud mode: close the cloud browser session via provider API
|
||||
|
||||
# Cloud mode: close the cloud browser session via provider API.
|
||||
# Local sidecars have bb_session_id=None so this no-ops for them.
|
||||
if bb_session_id:
|
||||
provider = _get_cloud_provider()
|
||||
if provider is not None:
|
||||
|
||||
@@ -440,9 +440,10 @@ def _get_or_create_env(task_id: str):
|
||||
_active_environments, _env_lock, _create_environment,
|
||||
_get_env_config, _last_activity, _start_cleanup_thread,
|
||||
_creation_locks, _creation_locks_lock, _task_env_overrides,
|
||||
_resolve_container_task_id,
|
||||
)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
effective_task_id = _resolve_container_task_id(task_id)
|
||||
|
||||
# Fast path: environment already exists
|
||||
with _env_lock:
|
||||
|
||||
+16
-2
@@ -88,8 +88,14 @@ def _resolve_path(filepath: str, task_id: str = "default") -> Path:
|
||||
|
||||
def _get_live_tracking_cwd(task_id: str = "default") -> str | None:
|
||||
"""Return the task's live terminal cwd for bookkeeping when available."""
|
||||
try:
|
||||
from tools.terminal_tool import _resolve_container_task_id
|
||||
container_key = _resolve_container_task_id(task_id)
|
||||
except Exception:
|
||||
container_key = task_id
|
||||
|
||||
with _file_ops_lock:
|
||||
cached = _file_ops_cache.get(task_id)
|
||||
cached = _file_ops_cache.get(container_key) or _file_ops_cache.get(task_id)
|
||||
if cached is not None:
|
||||
live_cwd = getattr(getattr(cached, "env", None), "cwd", None) or getattr(
|
||||
cached, "cwd", None
|
||||
@@ -101,7 +107,7 @@ def _get_live_tracking_cwd(task_id: str = "default") -> str | None:
|
||||
from tools.terminal_tool import _active_environments, _env_lock
|
||||
|
||||
with _env_lock:
|
||||
env = _active_environments.get(task_id)
|
||||
env = _active_environments.get(container_key) or _active_environments.get(task_id)
|
||||
live_cwd = getattr(env, "cwd", None) if env is not None else None
|
||||
if live_cwd:
|
||||
return live_cwd
|
||||
@@ -261,15 +267,23 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations:
|
||||
|
||||
Thread-safe: uses the same per-task creation locks as terminal_tool to
|
||||
prevent duplicate sandbox creation from concurrent tool calls.
|
||||
|
||||
Note: subagent task_ids are collapsed to "default" via
|
||||
``_resolve_container_task_id`` so delegate_task children share the
|
||||
parent's container and its cached file_ops. RL/benchmark task_ids with
|
||||
a registered env override keep their isolation.
|
||||
"""
|
||||
from tools.terminal_tool import (
|
||||
_active_environments, _env_lock, _create_environment,
|
||||
_get_env_config, _last_activity, _start_cleanup_thread,
|
||||
_creation_locks,
|
||||
_creation_locks_lock,
|
||||
_resolve_container_task_id,
|
||||
)
|
||||
import time
|
||||
|
||||
task_id = _resolve_container_task_id(task_id)
|
||||
|
||||
# Fast path: check cache -- but also verify the underlying environment
|
||||
# is still alive (it may have been killed by the cleanup thread).
|
||||
with _file_ops_lock:
|
||||
|
||||
@@ -776,7 +776,7 @@ class ProcessRegistry:
|
||||
|
||||
# Only enqueue completion notification on the FIRST move. Without
|
||||
# this guard, kill_process() and the reader thread can both call
|
||||
# _move_to_finished(), producing duplicate [SYSTEM: ...] messages.
|
||||
# _move_to_finished(), producing duplicate [IMPORTANT: ...] messages.
|
||||
if was_running and session.notify_on_complete:
|
||||
from tools.ansi_strip import strip_ansi
|
||||
output_tail = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else ""
|
||||
|
||||
@@ -20,6 +20,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_TELEGRAM_TOPIC_TARGET_RE = re.compile(r"^\s*(-?\d+)(?::(\d+))?\s*$")
|
||||
_FEISHU_TARGET_RE = re.compile(r"^\s*((?:oc|ou|on|chat|open)_[-A-Za-z0-9]+)(?::([-A-Za-z0-9_]+))?\s*$")
|
||||
# Slack conversation IDs: C (public channel), G (private/group channel), D (DM).
|
||||
# Must be uppercase alphanumeric, 9+ chars. User IDs (U...) and workspace IDs
|
||||
# (W...) are NOT valid chat.postMessage channel values — posting to them fails
|
||||
# because the API requires a conversation ID. To DM a user you must first call
|
||||
# conversations.open to obtain a D... ID. Without this gate, Slack IDs fall
|
||||
# through to channel-name resolution, which only matches by name and fails.
|
||||
_SLACK_TARGET_RE = re.compile(r"^\s*([CGD][A-Z0-9]{8,})\s*$")
|
||||
_WEIXIN_TARGET_RE = re.compile(r"^\s*((?:wxid|gh|v\d+|wm|wb)_[A-Za-z0-9_-]+|[A-Za-z0-9._-]+@chatroom|filehelper)\s*$")
|
||||
# Discord snowflake IDs are numeric, same regex pattern as Telegram topic targets.
|
||||
_NUMERIC_TOPIC_RE = _TELEGRAM_TOPIC_TARGET_RE
|
||||
@@ -318,6 +325,10 @@ def _parse_target_ref(platform_name: str, target_ref: str):
|
||||
match = _NUMERIC_TOPIC_RE.fullmatch(target_ref)
|
||||
if match:
|
||||
return match.group(1), match.group(2), True
|
||||
if platform_name == "slack":
|
||||
match = _SLACK_TARGET_RE.fullmatch(target_ref)
|
||||
if match:
|
||||
return match.group(1), None, True
|
||||
if platform_name == "weixin":
|
||||
match = _WEIXIN_TARGET_RE.fullmatch(target_ref)
|
||||
if match:
|
||||
|
||||
+32
-3
@@ -803,6 +803,31 @@ def clear_task_env_overrides(task_id: str):
|
||||
"""
|
||||
_task_env_overrides.pop(task_id, None)
|
||||
|
||||
|
||||
def _resolve_container_task_id(task_id: Optional[str]) -> str:
|
||||
"""
|
||||
Map a tool-call ``task_id`` to the container/sandbox key used by
|
||||
``_active_environments``.
|
||||
|
||||
The top-level agent passes ``task_id=None`` and lands on ``"default"``.
|
||||
``delegate_task`` children pass their own subagent ID so that
|
||||
file-state tracking, the active-subagents registry, and TUI events stay
|
||||
distinct per child -- but we deliberately collapse that ID back to
|
||||
``"default"`` here so subagents share the parent's long-lived container
|
||||
(one bash, one /workspace, one set of installed packages).
|
||||
|
||||
Exception: RL / benchmark environments (TerminalBench2, HermesSweEnv, ...)
|
||||
call ``register_task_env_overrides(task_id, {...})`` to request a
|
||||
per-task Docker/Modal image. When an override is registered for a
|
||||
task_id, we honour it by returning the task_id unchanged -- those
|
||||
rollouts need their own isolated sandbox, which is the whole point of
|
||||
the override.
|
||||
"""
|
||||
if task_id and task_id in _task_env_overrides:
|
||||
return task_id
|
||||
return "default"
|
||||
|
||||
|
||||
# Configuration from environment variables
|
||||
|
||||
def _parse_env_var(name: str, default: str, converter=int, type_label: str = "integer"):
|
||||
@@ -1139,8 +1164,9 @@ def _stop_cleanup_thread():
|
||||
|
||||
def get_active_env(task_id: str):
|
||||
"""Return the active BaseEnvironment for *task_id*, or None."""
|
||||
lookup = _resolve_container_task_id(task_id)
|
||||
with _env_lock:
|
||||
return _active_environments.get(task_id)
|
||||
return _active_environments.get(lookup) or _active_environments.get(task_id)
|
||||
|
||||
|
||||
def is_persistent_env(task_id: str) -> bool:
|
||||
@@ -1473,8 +1499,11 @@ def terminal_tool(
|
||||
config = _get_env_config()
|
||||
env_type = config["env_type"]
|
||||
|
||||
# Use task_id for environment isolation
|
||||
effective_task_id = task_id or "default"
|
||||
# Use task_id for environment isolation. By default all subagent
|
||||
# task_ids collapse back to "default" so the top-level agent and
|
||||
# every delegate_task child share one container; only task_ids with
|
||||
# a registered env override (RL benchmarks) get isolated sandboxes.
|
||||
effective_task_id = _resolve_container_task_id(task_id)
|
||||
|
||||
# Check per-task overrides (set by environments like TerminalBench2Env)
|
||||
# before falling back to global env var config
|
||||
|
||||
@@ -2321,6 +2321,26 @@ def _(rid, params: dict) -> dict:
|
||||
payload["rendered"] = rendered
|
||||
_emit("message.complete", sid, payload)
|
||||
|
||||
if (
|
||||
status == "complete"
|
||||
and isinstance(raw, str)
|
||||
and raw.strip()
|
||||
and isinstance(text, str)
|
||||
and text.strip()
|
||||
):
|
||||
try:
|
||||
from agent.title_generator import maybe_auto_title
|
||||
|
||||
maybe_auto_title(
|
||||
_get_db(),
|
||||
session.get("session_key") or sid,
|
||||
text,
|
||||
raw,
|
||||
session.get("history", []),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# CLI parity: when voice-mode TTS is on, speak the agent reply
|
||||
# (cli.py:_voice_speak_response). Only the final text — tool
|
||||
# calls / reasoning already stream separately and would be
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
boundedLiveRenderText,
|
||||
edgePreview,
|
||||
estimateRows,
|
||||
estimateTokensRough,
|
||||
@@ -106,3 +107,25 @@ describe('estimateRows', () => {
|
||||
expect(estimateRows(snake, w)).toBe(estimateRows(plain, w))
|
||||
})
|
||||
})
|
||||
|
||||
describe('boundedLiveRenderText', () => {
|
||||
it('keeps short text unchanged', () => {
|
||||
expect(boundedLiveRenderText('alpha\nbeta', { maxChars: 50, maxLines: 5 })).toBe('alpha\nbeta')
|
||||
})
|
||||
|
||||
it('keeps the tail of long live text', () => {
|
||||
const text = Array.from({ length: 6 }, (_, i) => `line-${i + 1}`).join('\n')
|
||||
const out = boundedLiveRenderText(text, { maxChars: 100, maxLines: 3 })
|
||||
|
||||
expect(out).toContain('omitted 3 lines')
|
||||
expect(out.endsWith('line-4\nline-5\nline-6')).toBe(true)
|
||||
expect(out).not.toContain('line-1')
|
||||
})
|
||||
|
||||
it('bounds very long single-line text by chars', () => {
|
||||
const out = boundedLiveRenderText('a'.repeat(60), { maxChars: 12, maxLines: 5 })
|
||||
|
||||
expect(out).toContain('omitted 48 chars')
|
||||
expect(out.endsWith('a'.repeat(12))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { REASONING_PULSE_MS, STREAM_BATCH_MS } from '../config/timing.js'
|
||||
import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js'
|
||||
import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
|
||||
import {
|
||||
boundedLiveRenderText,
|
||||
buildToolTrailLine,
|
||||
estimateTokensRough,
|
||||
isTransientTrailLine,
|
||||
@@ -492,7 +493,7 @@ class TurnController {
|
||||
this.streamTimer = null
|
||||
const raw = this.bufRef.trimStart()
|
||||
const visible = hasReasoningTag(raw) ? splitReasoning(raw).text : raw
|
||||
patchTurnState({ streaming: visible })
|
||||
patchTurnState({ streaming: boundedLiveRenderText(visible) })
|
||||
}, STREAM_BATCH_MS)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LONG_MSG } from '../config/limits.js'
|
||||
import { sectionMode } from '../domain/details.js'
|
||||
import { userDisplay } from '../domain/messages.js'
|
||||
import { ROLE } from '../domain/roles.js'
|
||||
import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js'
|
||||
import { boundedLiveRenderText, compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { DetailsMode, Msg, SectionVisibility } from '../types.js'
|
||||
|
||||
@@ -84,7 +84,11 @@ export const MessageLine = memo(function MessageLine({
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
return isStreaming ? <Text color={body}>{msg.text}</Text> : <Md compact={compact} t={t} text={msg.text} />
|
||||
return isStreaming ? (
|
||||
<Text color={body}>{boundedLiveRenderText(msg.text)}</Text>
|
||||
) : (
|
||||
<Md compact={compact} t={t} text={msg.text} />
|
||||
)
|
||||
}
|
||||
|
||||
if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
widthByDepth
|
||||
} from '../lib/subagentTree.js'
|
||||
import {
|
||||
boundedLiveRenderText,
|
||||
compactPreview,
|
||||
estimateTokensRough,
|
||||
fmtK,
|
||||
@@ -633,7 +634,12 @@ export const Thinking = memo(function Thinking({
|
||||
streaming?: boolean
|
||||
t: Theme
|
||||
}) {
|
||||
const preview = useMemo(() => thinkingPreview(reasoning, mode, THINKING_COT_MAX), [mode, reasoning])
|
||||
const preview = useMemo(() => {
|
||||
const raw = thinkingPreview(reasoning, mode, THINKING_COT_MAX)
|
||||
|
||||
return mode === 'full' ? boundedLiveRenderText(raw) : raw
|
||||
}, [mode, reasoning])
|
||||
|
||||
const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview])
|
||||
|
||||
if (!preview && !active) {
|
||||
@@ -868,8 +874,8 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
const hasTools = groups.length > 0
|
||||
const hasSubagents = subagents.length > 0
|
||||
const hasMeta = meta.length > 0
|
||||
const hasThinking = !!cot || reasoningActive || busy
|
||||
const thinkingLive = reasoningActive || reasoningStreaming
|
||||
const hasThinking = !!cot || thinkingLive
|
||||
|
||||
const tokenCount =
|
||||
reasoningTokens && reasoningTokens > 0 ? reasoningTokens : reasoning ? estimateTokensRough(reasoning) : 0
|
||||
@@ -1002,7 +1008,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
open: openThinking,
|
||||
render: rails => (
|
||||
<Thinking
|
||||
active={reasoningActive}
|
||||
active={thinkingLive}
|
||||
branch="last"
|
||||
mode="full"
|
||||
rails={rails}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const LARGE_PASTE = { chars: 8000, lines: 80 }
|
||||
export const LIVE_RENDER_MAX_CHARS = 16_000
|
||||
export const LIVE_RENDER_MAX_LINES = 240
|
||||
export const LONG_MSG = 300
|
||||
export const MAX_HISTORY = 800
|
||||
export const THINKING_COT_MAX = 160
|
||||
|
||||
@@ -14,7 +14,9 @@ const gw = new GatewayClient()
|
||||
gw.start()
|
||||
|
||||
const dumpNotice = (snap: MemorySnapshot, dump: HeapDumpResult | null) =>
|
||||
`hermes-tui: ${snap.level} memory (${formatBytes(snap.heapUsed)}) — auto heap dump → ${dump?.heapPath ?? '(failed)'}\n`
|
||||
snap.source === 'heap'
|
||||
? `hermes-tui: ${snap.level} heap (${formatBytes(snap.heapUsed)}, rss ${formatBytes(snap.rss)}) — auto heap dump → ${dump?.heapPath ?? '(failed)'}\n`
|
||||
: `hermes-tui: ${snap.level} rss (${formatBytes(snap.rss)}, native ${formatBytes(snap.nativeUsed)}) — auto diagnostics → ${dump?.diagPath ?? '(failed)'}\n`
|
||||
|
||||
setupGracefulExit({
|
||||
cleanups: [() => gw.kill()],
|
||||
|
||||
@@ -145,11 +145,11 @@ export async function performHeapDump(trigger: MemoryTrigger = 'manual'): Promis
|
||||
// Diagnostics first — heap-snapshot serialization can crash on very large
|
||||
// heaps, and the JSON sidecar is the most actionable artifact if so.
|
||||
const diagnostics = await captureMemoryDiagnostics(trigger)
|
||||
const dir = process.env.HERMES_HEAPDUMP_DIR?.trim() || join(homedir() || tmpdir(), '.hermes', 'heapdumps')
|
||||
const dir = memoryDumpDir()
|
||||
|
||||
await mkdir(dir, { recursive: true })
|
||||
|
||||
const base = `hermes-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}-${trigger}`
|
||||
const base = memoryDumpBase(trigger)
|
||||
const heapPath = join(dir, `${base}.heapsnapshot`)
|
||||
const diagPath = join(dir, `${base}.diagnostics.json`)
|
||||
|
||||
@@ -162,6 +162,23 @@ export async function performHeapDump(trigger: MemoryTrigger = 'manual'): Promis
|
||||
}
|
||||
}
|
||||
|
||||
export async function performDiagnosticsDump(trigger: MemoryTrigger = 'manual'): Promise<HeapDumpResult> {
|
||||
try {
|
||||
const diagnostics = await captureMemoryDiagnostics(trigger)
|
||||
const dir = memoryDumpDir()
|
||||
|
||||
await mkdir(dir, { recursive: true })
|
||||
|
||||
const diagPath = join(dir, `${memoryDumpBase(trigger)}.diagnostics.json`)
|
||||
|
||||
await writeFile(diagPath, JSON.stringify(diagnostics, null, 2), { mode: 0o600 })
|
||||
|
||||
return { diagPath, success: true }
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : String(e), success: false }
|
||||
}
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return '0B'
|
||||
@@ -177,6 +194,11 @@ const UNITS = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
|
||||
const STARTED_AT = { rss: process.memoryUsage().rss, uptime: process.uptime() }
|
||||
|
||||
const memoryDumpDir = () => process.env.HERMES_HEAPDUMP_DIR?.trim() || join(homedir() || tmpdir(), '.hermes', 'heapdumps')
|
||||
|
||||
const memoryDumpBase = (trigger: MemoryTrigger) =>
|
||||
`hermes-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}-${trigger}`
|
||||
|
||||
// Returns undefined when the probe isn't available (non-Linux paths, sandboxed FS).
|
||||
const swallow = async <T>(fn: () => Promise<T>): Promise<T | undefined> => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const memory = vi.hoisted(() => ({
|
||||
performDiagnosticsDump: vi.fn(async () => ({ diagPath: '/tmp/diag.json', success: true })),
|
||||
performHeapDump: vi.fn(async () => ({ heapPath: '/tmp/heap.heapsnapshot', success: true }))
|
||||
}))
|
||||
|
||||
vi.mock('./memory.js', () => memory)
|
||||
|
||||
import { type MemorySnapshot, startMemoryMonitor } from './memoryMonitor.js'
|
||||
|
||||
const GB = 1024 ** 3
|
||||
|
||||
const usage = (heapUsed: number, rss: number): NodeJS.MemoryUsage =>
|
||||
({
|
||||
arrayBuffers: 0,
|
||||
external: 0,
|
||||
heapTotal: heapUsed,
|
||||
heapUsed,
|
||||
rss
|
||||
}) as NodeJS.MemoryUsage
|
||||
|
||||
describe('startMemoryMonitor', () => {
|
||||
let memoryUsageSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
memory.performDiagnosticsDump.mockClear()
|
||||
memory.performHeapDump.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
memoryUsageSpy?.mockRestore()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('captures diagnostics only for native RSS pressure', async () => {
|
||||
memoryUsageSpy = vi.spyOn(process, 'memoryUsage').mockReturnValue(usage(100 * 1024 ** 2, 5 * GB))
|
||||
|
||||
const snaps: MemorySnapshot[] = []
|
||||
|
||||
const stop = startMemoryMonitor({
|
||||
intervalMs: 1000,
|
||||
onHigh: snap => snaps.push(snap),
|
||||
rssHighBytes: 4 * GB
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
stop()
|
||||
|
||||
expect(memory.performDiagnosticsDump).toHaveBeenCalledWith('auto-high')
|
||||
expect(memory.performHeapDump).not.toHaveBeenCalled()
|
||||
expect(snaps[0]).toMatchObject({ level: 'high', source: 'rss' })
|
||||
expect(snaps[0]?.nativeUsed).toBeGreaterThan(4 * GB)
|
||||
})
|
||||
|
||||
it('keeps heap dumps for V8 heap pressure', async () => {
|
||||
memoryUsageSpy = vi.spyOn(process, 'memoryUsage').mockReturnValue(usage(3 * GB, 3.5 * GB))
|
||||
|
||||
const snaps: MemorySnapshot[] = []
|
||||
|
||||
const stop = startMemoryMonitor({
|
||||
intervalMs: 1000,
|
||||
onCritical: snap => snaps.push(snap)
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
stop()
|
||||
|
||||
expect(memory.performHeapDump).toHaveBeenCalledWith('auto-critical')
|
||||
expect(memory.performDiagnosticsDump).not.toHaveBeenCalled()
|
||||
expect(snaps[0]).toMatchObject({ level: 'critical', source: 'heap' })
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,14 @@
|
||||
import { type HeapDumpResult, performHeapDump } from './memory.js'
|
||||
import { type HeapDumpResult, performDiagnosticsDump, performHeapDump } from './memory.js'
|
||||
|
||||
export type MemoryLevel = 'critical' | 'high' | 'normal'
|
||||
export type MemoryTriggerSource = 'heap' | 'rss'
|
||||
|
||||
export interface MemorySnapshot {
|
||||
heapUsed: number
|
||||
level: MemoryLevel
|
||||
nativeUsed: number
|
||||
rss: number
|
||||
source: MemoryTriggerSource
|
||||
}
|
||||
|
||||
export interface MemoryMonitorOptions {
|
||||
@@ -14,35 +17,61 @@ export interface MemoryMonitorOptions {
|
||||
intervalMs?: number
|
||||
onCritical?: (snap: MemorySnapshot, dump: HeapDumpResult | null) => void
|
||||
onHigh?: (snap: MemorySnapshot, dump: HeapDumpResult | null) => void
|
||||
rssCriticalBytes?: number
|
||||
rssHighBytes?: number
|
||||
}
|
||||
|
||||
const GB = 1024 ** 3
|
||||
|
||||
const maxLevel = (heapLevel: MemoryLevel, rssLevel: MemoryLevel): MemoryLevel => {
|
||||
if (heapLevel === 'critical' || rssLevel === 'critical') {
|
||||
return 'critical'
|
||||
}
|
||||
|
||||
return heapLevel === 'high' || rssLevel === 'high' ? 'high' : 'normal'
|
||||
}
|
||||
|
||||
export function startMemoryMonitor({
|
||||
criticalBytes = 2.5 * GB,
|
||||
highBytes = 1.5 * GB,
|
||||
intervalMs = 10_000,
|
||||
onCritical,
|
||||
onHigh
|
||||
onHigh,
|
||||
rssCriticalBytes = 8 * GB,
|
||||
rssHighBytes = 4 * GB
|
||||
}: MemoryMonitorOptions = {}): () => void {
|
||||
const dumped = new Set<Exclude<MemoryLevel, 'normal'>>()
|
||||
const dumped = new Set<`${MemoryTriggerSource}:${Exclude<MemoryLevel, 'normal'>}`>()
|
||||
|
||||
const tick = async () => {
|
||||
const { heapUsed, rss } = process.memoryUsage()
|
||||
const level: MemoryLevel = heapUsed >= criticalBytes ? 'critical' : heapUsed >= highBytes ? 'high' : 'normal'
|
||||
const nativeUsed = Math.max(0, rss - heapUsed)
|
||||
const heapLevel: MemoryLevel = heapUsed >= criticalBytes ? 'critical' : heapUsed >= highBytes ? 'high' : 'normal'
|
||||
const rssLevel: MemoryLevel = rss >= rssCriticalBytes ? 'critical' : rss >= rssHighBytes ? 'high' : 'normal'
|
||||
const level = maxLevel(heapLevel, rssLevel)
|
||||
|
||||
if (level === 'normal') {
|
||||
return void dumped.clear()
|
||||
}
|
||||
|
||||
if (dumped.has(level)) {
|
||||
const source: MemoryTriggerSource =
|
||||
heapLevel === level || (heapLevel !== 'normal' && rssLevel === level) ? 'heap' : 'rss'
|
||||
|
||||
const key = `${source}:${level}` as const
|
||||
|
||||
if (dumped.has(key)) {
|
||||
return
|
||||
}
|
||||
|
||||
dumped.add(level)
|
||||
const dump = await performHeapDump(level === 'critical' ? 'auto-critical' : 'auto-high').catch(() => null)
|
||||
dumped.add(key)
|
||||
|
||||
const snap: MemorySnapshot = { heapUsed, level, rss }
|
||||
const trigger = level === 'critical' ? 'auto-critical' : 'auto-high'
|
||||
|
||||
const dump =
|
||||
source === 'heap'
|
||||
? await performHeapDump(trigger).catch(() => null)
|
||||
: await performDiagnosticsDump(trigger).catch(() => null)
|
||||
|
||||
const snap: MemorySnapshot = { heapUsed, level, nativeUsed, rss, source }
|
||||
|
||||
;(level === 'critical' ? onCritical : onHigh)?.(snap, dump)
|
||||
}
|
||||
|
||||
+56
-1
@@ -1,4 +1,4 @@
|
||||
import { THINKING_COT_MAX } from '../config/limits.js'
|
||||
import { LIVE_RENDER_MAX_CHARS, LIVE_RENDER_MAX_LINES, THINKING_COT_MAX } from '../config/limits.js'
|
||||
import type { ThinkingMode } from '../types.js'
|
||||
|
||||
const ESC = String.fromCharCode(27)
|
||||
@@ -76,6 +76,61 @@ export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: numb
|
||||
return !raw || mode === 'collapsed' ? '' : mode === 'full' ? raw : compactPreview(raw.replace(WS_RE, ' '), max)
|
||||
}
|
||||
|
||||
export const boundedLiveRenderText = (
|
||||
text: string,
|
||||
{ maxChars = LIVE_RENDER_MAX_CHARS, maxLines = LIVE_RENDER_MAX_LINES } = {}
|
||||
) => {
|
||||
if (text.length <= maxChars && text.split('\n', maxLines + 1).length <= maxLines) {
|
||||
return text
|
||||
}
|
||||
|
||||
let start = 0
|
||||
let idx = text.length
|
||||
|
||||
for (let seen = 0; seen < maxLines && idx > 0; seen++) {
|
||||
idx = text.lastIndexOf('\n', idx - 1)
|
||||
start = idx < 0 ? 0 : idx + 1
|
||||
|
||||
if (idx < 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const lineStart = start
|
||||
start = Math.max(lineStart, text.length - maxChars)
|
||||
|
||||
if (start > lineStart) {
|
||||
const nextBreak = text.indexOf('\n', start)
|
||||
|
||||
if (nextBreak >= 0 && nextBreak < text.length - 1) {
|
||||
start = nextBreak + 1
|
||||
}
|
||||
}
|
||||
|
||||
const tail = text.slice(start).trimStart()
|
||||
const omittedLines = countNewlines(text, start)
|
||||
const omittedChars = Math.max(0, text.length - tail.length)
|
||||
|
||||
const label =
|
||||
omittedLines > 0
|
||||
? `[showing live tail; omitted ${fmtK(omittedLines)} lines / ${fmtK(omittedChars)} chars]\n`
|
||||
: `[showing live tail; omitted ${fmtK(omittedChars)} chars]\n`
|
||||
|
||||
return `${label}${tail.trimStart()}`
|
||||
}
|
||||
|
||||
const countNewlines = (text: string, end: number) => {
|
||||
let count = 0
|
||||
|
||||
for (let i = 0; i < end; i++) {
|
||||
if (text.charCodeAt(i) === 10) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text)
|
||||
|
||||
export const toolTrailLabel = (name: string) =>
|
||||
|
||||
@@ -41,6 +41,7 @@ hermes [global-options] <command> [subcommand/options]
|
||||
| `hermes gateway` | Run or manage the messaging gateway service. |
|
||||
| `hermes setup` | Interactive setup wizard for all or part of the configuration. |
|
||||
| `hermes whatsapp` | Configure and pair the WhatsApp bridge. |
|
||||
| `hermes slack` | Slack helpers (currently: generate the app manifest with every command as a native slash). |
|
||||
| `hermes auth` | Manage credentials — add, list, remove, reset, set strategy. Handles OAuth flows for Codex/Nous/Anthropic. |
|
||||
| `hermes login` / `logout` | **Deprecated** — use `hermes auth` instead. |
|
||||
| `hermes status` | Show agent, auth, and platform status. |
|
||||
@@ -221,6 +222,33 @@ hermes whatsapp
|
||||
|
||||
Runs the WhatsApp pairing/setup flow, including mode selection and QR-code pairing.
|
||||
|
||||
## `hermes slack`
|
||||
|
||||
```bash
|
||||
hermes slack manifest # print manifest to stdout
|
||||
hermes slack manifest --write # write to ~/.hermes/slack-manifest.json
|
||||
hermes slack manifest --slashes-only # just the features.slash_commands array
|
||||
```
|
||||
|
||||
Generates a Slack app manifest that registers every gateway command in
|
||||
`COMMAND_REGISTRY` (`/btw`, `/stop`, `/model`, …) as a first-class
|
||||
Slack slash command — matching Discord and Telegram parity. Paste the
|
||||
output into your Slack app config at
|
||||
[https://api.slack.com/apps](https://api.slack.com/apps) → your app →
|
||||
**Features → App Manifest → Edit**, then **Save**. Slack prompts for
|
||||
reinstall if scopes or slash commands changed.
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|------|---------|---------|
|
||||
| `--write [PATH]` | stdout | Write to a file instead of stdout. Bare `--write` writes `$HERMES_HOME/slack-manifest.json`. |
|
||||
| `--name NAME` | `Hermes` | Bot display name in Slack. |
|
||||
| `--description DESC` | default blurb | Bot description shown in the Slack app directory. |
|
||||
| `--slashes-only` | off | Emit only `features.slash_commands` for merging into a manually-maintained manifest. |
|
||||
|
||||
Run `hermes slack manifest --write` again after `hermes update` to pick
|
||||
up any new commands.
|
||||
|
||||
|
||||
## `hermes login` / `hermes logout` *(Deprecated)*
|
||||
|
||||
:::caution
|
||||
|
||||
@@ -146,7 +146,9 @@ terminal:
|
||||
|
||||
**Requirements:** Docker Desktop or Docker Engine installed and running. Hermes probes `$PATH` plus common macOS install locations (`/usr/local/bin/docker`, `/opt/homebrew/bin/docker`, Docker Desktop app bundle).
|
||||
|
||||
**Container lifecycle:** Each session starts a long-lived container (`docker run -d ... sleep 2h`). Commands run via `docker exec` with a login shell. On cleanup, the container is stopped and removed.
|
||||
**Container lifecycle:** Hermes reuses a single long-lived container (`docker run -d ... sleep 2h`) for every terminal and file-tool call, across sessions, `/new`, `/reset`, and `delegate_task` subagents, for the lifetime of the Hermes process. Commands run via `docker exec` with a login shell, so working-directory changes, installed packages, and files in `/workspace` all persist from one tool call to the next. The container is stopped and removed on Hermes shutdown (or when the idle-sweep reclaims it).
|
||||
|
||||
Parallel subagents spawned via `delegate_task(tasks=[...])` share this one container — concurrent `cd`, env mutations, and writes to the same path will collide. If a subagent needs an isolated sandbox, it must register a per-task image override via `register_task_env_overrides()`, which RL and benchmark environments (TerminalBench2, HermesSweEnv, etc.) do automatically for their per-task Docker images.
|
||||
|
||||
**Security hardening:**
|
||||
- `--cap-drop ALL` with only `DAC_OVERRIDE`, `CHOWN`, `FOWNER` added back
|
||||
|
||||
@@ -86,6 +86,40 @@ FIRECRAWL_API_URL=http://localhost:3002
|
||||
FIRECRAWL_BROWSER_TTL=600
|
||||
```
|
||||
|
||||
### Hybrid routing: cloud for public URLs, local for LAN/localhost
|
||||
|
||||
When a cloud provider is configured, Hermes auto-spawns a **local Chromium sidecar**
|
||||
for URLs that resolve to a private/loopback/LAN address (`localhost`, `127.0.0.1`,
|
||||
`192.168.x.x`, `10.x.x.x`, `172.16-31.x.x`, `*.local`, `*.lan`, `*.internal`,
|
||||
IPv6 loopback `::1`, link-local `169.254.x.x`). Public URLs continue to use the
|
||||
cloud provider in the same conversation.
|
||||
|
||||
This solves the common "I'm developing locally but using Browserbase" workflow —
|
||||
the agent can screenshot your dashboard at `http://localhost:3000` AND scrape
|
||||
`https://github.com` without you switching providers or disabling the SSRF guard.
|
||||
The cloud provider never sees the private URL.
|
||||
|
||||
The feature is **on by default**. To disable it (all URLs go to the configured
|
||||
cloud provider, as before):
|
||||
|
||||
```yaml
|
||||
# ~/.hermes/config.yaml
|
||||
browser:
|
||||
cloud_provider: browserbase
|
||||
auto_local_for_private_urls: false
|
||||
```
|
||||
|
||||
With auto-routing disabled, private URLs are rejected with
|
||||
`"Blocked: URL targets a private or internal address"` unless you also set
|
||||
`browser.allow_private_urls: true` (which lets the cloud provider attempt them —
|
||||
usually won't work since Browserbase etc. can't reach your LAN).
|
||||
|
||||
Requirements: the local sidecar uses the same `agent-browser` CLI as pure local
|
||||
mode, so you need it installed (`hermes setup tools → Browser Automation`
|
||||
auto-installs it). Post-navigation redirects from a public URL onto a private
|
||||
address are still blocked (you can't use a redirect-to-internal trick to reach
|
||||
your LAN through the public path).
|
||||
|
||||
### Camofox local mode
|
||||
|
||||
[Camofox](https://github.com/jo-inc/camofox-browser) is a self-hosted Node.js server wrapping Camoufox (a Firefox fork with C++ fingerprint spoofing). It provides local anti-detection browsing without cloud dependencies.
|
||||
|
||||
@@ -29,13 +29,36 @@ the steps below.
|
||||
|
||||
## Step 1: Create a Slack App
|
||||
|
||||
The fastest path is to paste a manifest Hermes generates for you. It
|
||||
declares every built-in slash command (`/btw`, `/stop`, `/model`, …),
|
||||
every required OAuth scope, every event subscription, and enables Socket
|
||||
Mode — all at once.
|
||||
|
||||
### Option A: From a Hermes-generated manifest (recommended)
|
||||
|
||||
1. Generate the manifest:
|
||||
```bash
|
||||
hermes slack manifest --write
|
||||
```
|
||||
This writes `~/.hermes/slack-manifest.json` and prints paste-in
|
||||
instructions.
|
||||
2. Go to [https://api.slack.com/apps](https://api.slack.com/apps) →
|
||||
**Create New App** → **From an app manifest**
|
||||
3. Pick your workspace, paste the JSON contents, review, click **Next**
|
||||
→ **Create**
|
||||
4. Skip ahead to **Step 6: Install App to Workspace**. The manifest
|
||||
handled scopes, events, and slash commands for you.
|
||||
|
||||
### Option B: From scratch (manual)
|
||||
|
||||
1. Go to [https://api.slack.com/apps](https://api.slack.com/apps)
|
||||
2. Click **Create New App**
|
||||
3. Choose **From scratch**
|
||||
4. Enter an app name (e.g., "Hermes Agent") and select your workspace
|
||||
5. Click **Create App**
|
||||
|
||||
You'll land on the app's **Basic Information** page.
|
||||
You'll land on the app's **Basic Information** page. Continue with
|
||||
Steps 2–6 below.
|
||||
|
||||
---
|
||||
|
||||
@@ -59,7 +82,8 @@ Navigate to **Features → OAuth & Permissions** in the sidebar. Scroll to **Sco
|
||||
|
||||
:::caution Missing scopes = missing features
|
||||
Without `channels:history` and `groups:history`, the bot **will not receive messages in channels** —
|
||||
it will only work in DMs. These are the most commonly missed scopes.
|
||||
it will only work in DMs. Without `files:read`, Hermes can chat but **cannot reliably read user-uploaded attachments**.
|
||||
These are the most commonly missed scopes.
|
||||
:::
|
||||
|
||||
**Optional scopes:**
|
||||
@@ -203,6 +227,57 @@ The bot will **not** automatically join channels. You must invite it to each cha
|
||||
|
||||
---
|
||||
|
||||
## Slash Commands
|
||||
|
||||
Every Hermes command (`/btw`, `/stop`, `/new`, `/model`, `/help`, ...)
|
||||
is a native Slack slash command — exactly the way they work on Telegram
|
||||
and Discord. Type `/` in Slack and the autocomplete picker lists every
|
||||
Hermes command with its description.
|
||||
|
||||
Under the hood: Hermes ships with a generated Slack app manifest (see
|
||||
Step 1, Option A) that declares every command in
|
||||
[`COMMAND_REGISTRY`](https://github.com/NousResearch/hermes-agent/blob/main/hermes_cli/commands.py)
|
||||
as a slash command. In Socket Mode, Slack routes the command event
|
||||
through the WebSocket regardless of the manifest's `url` field.
|
||||
|
||||
### Refreshing slash commands after updates
|
||||
|
||||
When Hermes adds new commands (e.g. after `hermes update`), regenerate
|
||||
the manifest and update your Slack app:
|
||||
|
||||
```bash
|
||||
hermes slack manifest --write
|
||||
```
|
||||
|
||||
Then in Slack:
|
||||
1. Open [https://api.slack.com/apps](https://api.slack.com/apps) →
|
||||
your Hermes app
|
||||
2. **Features → App Manifest → Edit**
|
||||
3. Paste the new contents of `~/.hermes/slack-manifest.json`
|
||||
4. **Save**. Slack will prompt to reinstall the app if scopes or slash
|
||||
commands changed.
|
||||
|
||||
### Legacy `/hermes <subcommand>` still works
|
||||
|
||||
For backward compatibility with older manifests, you can still type
|
||||
`/hermes btw run the tests` — Hermes routes it the same way as `/btw
|
||||
run the tests`. Free-form questions also work: `/hermes what's the
|
||||
weather?` is treated as a regular message.
|
||||
|
||||
### Advanced: emit only the slash-commands array
|
||||
|
||||
If you maintain your Slack manifest by hand and just want the slash
|
||||
command list:
|
||||
|
||||
```bash
|
||||
hermes slack manifest --slashes-only > /tmp/slashes.json
|
||||
```
|
||||
|
||||
Paste that array into the `features.slash_commands` key of your
|
||||
existing manifest.
|
||||
|
||||
---
|
||||
|
||||
## How the Bot Responds
|
||||
|
||||
Understanding how Hermes behaves in different contexts:
|
||||
@@ -446,7 +521,8 @@ Keys are Slack channel IDs (find them via channel details → "About" → scroll
|
||||
| "Sending messages to this app has been turned off" in DMs | Enable the **Messages Tab** in App Home settings (see Step 5) |
|
||||
| "not_authed" or "invalid_auth" errors | Regenerate your Bot Token and App Token, update `.env` |
|
||||
| Bot responds but can't post in a channel | Invite the bot to the channel with `/invite @Hermes Agent` |
|
||||
| "missing_scope" error | Add the required scope in OAuth & Permissions, then **reinstall** the app |
|
||||
| Bot can chat but can't read uploaded images/files | Add `files:read`, then **reinstall** the app. Hermes now surfaces attachment access diagnostics in-chat when Slack returns scope/auth/permission failures. |
|
||||
| `missing_scope` error | Add the required scope in OAuth & Permissions, then **reinstall** the app |
|
||||
| Socket disconnects frequently | Check your network; Bolt auto-reconnects but unstable connections cause lag |
|
||||
| Changed scopes/events but nothing changed | You **must reinstall** the app to your workspace after any scope or event subscription change |
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ CATEGORY_LABELS = {
|
||||
"dogfood": "Dogfood",
|
||||
"domain": "Domain",
|
||||
"email": "Email",
|
||||
"feeds": "Feeds",
|
||||
"gaming": "Gaming",
|
||||
"gifs": "GIFs",
|
||||
"github": "GitHub",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updated_at": "2026-04-26T12:34:42Z",
|
||||
"updated_at": "2026-04-26T19:27:12Z",
|
||||
"metadata": {
|
||||
"source": "hermes-agent repo",
|
||||
"docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog"
|
||||
@@ -16,14 +16,6 @@
|
||||
"id": "moonshotai/kimi-k2.6",
|
||||
"description": "recommended"
|
||||
},
|
||||
{
|
||||
"id": "deepseek/deepseek-v4-pro",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"id": "deepseek/deepseek-v4-flash",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-opus-4.7",
|
||||
"description": ""
|
||||
@@ -163,12 +155,6 @@
|
||||
{
|
||||
"id": "moonshotai/kimi-k2.6"
|
||||
},
|
||||
{
|
||||
"id": "deepseek/deepseek-v4-pro"
|
||||
},
|
||||
{
|
||||
"id": "deepseek/deepseek-v4-flash"
|
||||
},
|
||||
{
|
||||
"id": "xiaomi/mimo-v2.5-pro"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user