Compare commits
196 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9393296fc | |||
| e63929d4f3 | |||
| 859e09b7ce | |||
| 898ccfd667 | |||
| 6c87371815 | |||
| 517f30b043 | |||
| 9c416e20ab | |||
| d308ae27e1 | |||
| b288934dff | |||
| e19854d893 | |||
| 6993e566ba | |||
| 91512b8210 | |||
| 366351b94d | |||
| 16e243e067 | |||
| 3e1664923d | |||
| c23463fce9 | |||
| de790eaceb | |||
| d81b1cd86c | |||
| 7945fcef21 | |||
| ffa33e53f6 | |||
| 635948d0e0 | |||
| c2ca02fcff | |||
| b51c528613 | |||
| 625c31fcea | |||
| dda12775f2 | |||
| 2e4b65b9f5 | |||
| cb51baeceb | |||
| e85b752516 | |||
| 7da2f07641 | |||
| 478444c262 | |||
| ced8f44cd2 | |||
| 977d5f56c9 | |||
| a32b325d06 | |||
| 419535f07f | |||
| e504a599fe | |||
| dbe5015566 | |||
| ebad6d3f1e | |||
| 87610ce380 | |||
| f66ebe64e8 | |||
| 36b13709f5 | |||
| 77d4766602 | |||
| 00c6480a05 | |||
| 88a85d30c1 | |||
| cebf95854b | |||
| 34eb1aaa9a | |||
| ab6879634e | |||
| 5eb6cd82b2 | |||
| 7e3c8a31f0 | |||
| 0bef0b9416 | |||
| 55e9329ee6 | |||
| 0d4247d9bf | |||
| c997183f53 | |||
| f01e4402a9 | |||
| 5b5a53a155 | |||
| 90c84c6dba | |||
| bdaf56a94d | |||
| b1c49d5e73 | |||
| bdc1adf711 | |||
| 55f212a7a2 | |||
| 7eaad06a87 | |||
| a01e767b24 | |||
| fd474d0f00 | |||
| cd2aee36ca | |||
| 3b60abb6bb | |||
| 0ba6471dd1 | |||
| 7317d69f19 | |||
| 2a0fc97c76 | |||
| 8fb861ea6e | |||
| 635253b918 | |||
| 87477756fd | |||
| 930494d687 | |||
| 5db6db891c | |||
| e818ec520a | |||
| 527ac351b4 | |||
| b115ea62da | |||
| 25767513f2 | |||
| c370e2e1e5 | |||
| b16f9d438b | |||
| 85e9a23efb | |||
| 4395c2b007 | |||
| 0cd98499bb | |||
| 4cdb6962ca | |||
| 9a46feb9bd | |||
| 8d2b08342c | |||
| 82f842277e | |||
| f823535db2 | |||
| d3dedf10aa | |||
| 7ca16eea56 | |||
| 4a9070c9ac | |||
| 7242361a69 | |||
| cd7a200e6c | |||
| 71eee26640 | |||
| 69ff201050 | |||
| 2259eac49e | |||
| cb7cfba6de | |||
| debae25f1c | |||
| bde89c169b | |||
| b36007b246 | |||
| c78b528125 | |||
| 319c1c1691 | |||
| 4943ea2a7c | |||
| 4d3e3a738d | |||
| a5319fb7af | |||
| f5552f92e2 | |||
| 1566f1eecc | |||
| a30db69dd5 | |||
| f6846205cc | |||
| 6a3873942f | |||
| 64de685d3f | |||
| cee4036e8b | |||
| cf8439263a | |||
| 3271ffbd80 | |||
| a7831b63db | |||
| d4dde6b5f2 | |||
| 755a280424 | |||
| 6087e04043 | |||
| 4921b26945 | |||
| 822b507a72 | |||
| 18beb69b49 | |||
| bf05b8f4a2 | |||
| 778fd1898e | |||
| 45bfcb9e71 | |||
| aa7b5acfcd | |||
| 36e352afa7 | |||
| 2d86e97a7e | |||
| edadeaf495 | |||
| f9885130b4 | |||
| f414df3a56 | |||
| c0d25df311 | |||
| 10e36188da | |||
| 6a3102f9d4 | |||
| 75d3eaa0e4 | |||
| 802c7acb81 | |||
| 541cd732e8 | |||
| 4d119bb62a | |||
| 878c196738 | |||
| 50dd67c680 | |||
| aea4a90f0e | |||
| 897dc3a2bb | |||
| 350ee1bf23 | |||
| 3d21f97422 | |||
| 4b5a88d714 | |||
| b1be86ef96 | |||
| 7b5b524fc7 | |||
| a30ffbe1d4 | |||
| c9f7b703dd | |||
| a8bfe72d35 | |||
| ae7687cdc5 | |||
| c730f6cc0b | |||
| d993a3f450 | |||
| 1dfcc2ffc3 | |||
| 5b2c59559a | |||
| 2be5e181a9 | |||
| 015f6c825d | |||
| bb59d3bac2 | |||
| 4a21920b5e | |||
| cc16d0ef77 | |||
| 087e74d4d7 | |||
| a8fcd1c742 | |||
| 9be83728a6 | |||
| 9397767513 | |||
| 9662e3218a | |||
| 0824ba6a9d | |||
| 42c076d349 | |||
| 0e2a53eab2 | |||
| 6814646b36 | |||
| eaa7e2db67 | |||
| 4e356098d2 | |||
| de24315978 | |||
| 20cb706e03 | |||
| d7a3468246 | |||
| f2d655529a | |||
| 27f4dba5ce | |||
| 8443998dc3 | |||
| d91e24547c | |||
| 05dc2eec36 | |||
| 2e6c3c7d23 | |||
| a0aebad673 | |||
| 7143d22a83 | |||
| 5ac4088856 | |||
| e16e196c7e | |||
| 7d68ea9501 | |||
| bc17310442 | |||
| 8f0fa0836f | |||
| bbd950efcf | |||
| 381121025e | |||
| 355e0ae960 | |||
| 1c964ed43f | |||
| cd7c5e5606 | |||
| ee7ef33b02 | |||
| 5cd41d2b3b | |||
| 9bb3bc422d | |||
| 19d75d1797 | |||
| 458ce792d2 | |||
| 14fcff60c9 | |||
| db4e4acca0 |
@@ -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}"
|
||||
|
||||
+52
-5
@@ -25,6 +25,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
BUSY_INPUT_FLAG = "busy_input_prompt"
|
||||
TOOL_PROGRESS_FLAG = "tool_progress_prompt"
|
||||
OPENCLAW_RESIDUE_FLAG = "openclaw_residue_cleanup"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -43,10 +44,18 @@ def busy_input_hint_gateway(mode: str) -> str:
|
||||
"Send `/busy interrupt` to make new messages stop the current task "
|
||||
"immediately, or `/busy status` to check. This notice won't appear again."
|
||||
)
|
||||
if mode == "steer":
|
||||
return (
|
||||
"💡 First-time tip — I steered your message into the current run; "
|
||||
"it will arrive after the next tool call instead of interrupting. "
|
||||
"Send `/busy interrupt` or `/busy queue` to change this, or "
|
||||
"`/busy status` to check. This notice won't appear again."
|
||||
)
|
||||
return (
|
||||
"💡 First-time tip — I just interrupted my current task to answer you. "
|
||||
"Send `/busy queue` to queue follow-ups for after the current task instead, "
|
||||
"or `/busy status` to check. This notice won't appear again."
|
||||
"`/busy steer` to inject them mid-run without interrupting, or "
|
||||
"`/busy status` to check. This notice won't appear again."
|
||||
)
|
||||
|
||||
|
||||
@@ -55,13 +64,19 @@ def busy_input_hint_cli(mode: str) -> str:
|
||||
if mode == "queue":
|
||||
return (
|
||||
"(tip) Your message was queued for the next turn. "
|
||||
"Use /busy interrupt to make Enter stop the current run instead. "
|
||||
"This tip only shows once."
|
||||
"Use /busy interrupt to make Enter stop the current run instead, "
|
||||
"or /busy steer to inject mid-run. This tip only shows once."
|
||||
)
|
||||
if mode == "steer":
|
||||
return (
|
||||
"(tip) Your message was steered into the current run; it arrives "
|
||||
"after the next tool call. Use /busy interrupt or /busy queue to "
|
||||
"change this. This tip only shows once."
|
||||
)
|
||||
return (
|
||||
"(tip) Your message interrupted the current run. "
|
||||
"Use /busy queue to queue messages for the next turn instead. "
|
||||
"This tip only shows once."
|
||||
"Use /busy queue to queue messages for the next turn instead, "
|
||||
"or /busy steer to inject mid-run. This tip only shows once."
|
||||
)
|
||||
|
||||
|
||||
@@ -80,6 +95,35 @@ def tool_progress_hint_cli() -> str:
|
||||
)
|
||||
|
||||
|
||||
def openclaw_residue_hint_cli() -> str:
|
||||
"""Banner shown the first time Hermes starts and finds ``~/.openclaw/``.
|
||||
|
||||
OpenClaw-era config, memory, and skill paths in ``~/.openclaw/`` will
|
||||
otherwise attract the agent (memory entries like ``~/.openclaw/config.yaml``
|
||||
get carried forward and the agent dutifully reads them). ``hermes claw
|
||||
cleanup`` renames the directory so the agent stops finding it.
|
||||
"""
|
||||
return (
|
||||
"Heads up — an OpenClaw workspace was detected at ~/.openclaw/.\n"
|
||||
"After migrating, the agent can still get confused and read that "
|
||||
"directory's config/memory instead of Hermes's.\n"
|
||||
"Run `hermes claw cleanup` to archive it (rename → .openclaw.pre-migration). "
|
||||
"This tip only shows once; rerun it any time with `hermes claw cleanup`."
|
||||
)
|
||||
|
||||
|
||||
def detect_openclaw_residue(home: Optional[Path] = None) -> bool:
|
||||
"""Return True if an OpenClaw workspace directory is present in ``$HOME``.
|
||||
|
||||
Pure filesystem check — no side effects. ``home`` override exists for tests.
|
||||
"""
|
||||
base = home or Path.home()
|
||||
try:
|
||||
return (base / ".openclaw").is_dir()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# State read / write
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -135,10 +179,13 @@ def mark_seen(config_path: Path, flag: str) -> bool:
|
||||
__all__ = [
|
||||
"BUSY_INPUT_FLAG",
|
||||
"TOOL_PROGRESS_FLAG",
|
||||
"OPENCLAW_RESIDUE_FLAG",
|
||||
"busy_input_hint_gateway",
|
||||
"busy_input_hint_cli",
|
||||
"tool_progress_hint_gateway",
|
||||
"tool_progress_hint_cli",
|
||||
"openclaw_residue_hint_cli",
|
||||
"detect_openclaw_residue",
|
||||
"is_seen",
|
||||
"mark_seen",
|
||||
]
|
||||
|
||||
@@ -422,6 +422,29 @@ PLATFORM_HINTS = {
|
||||
"your response. Images are sent as native photos, and other files arrive as downloadable "
|
||||
"documents."
|
||||
),
|
||||
"yuanbao": (
|
||||
"You are on Yuanbao (腾讯元宝), a Chinese AI assistant platform. "
|
||||
"Markdown formatting is supported (code blocks, tables, bold/italic). "
|
||||
"You CAN send media files natively — to deliver a file to the user, include "
|
||||
"MEDIA:/absolute/path/to/file in your response. The file will be sent as a native "
|
||||
"Yuanbao attachment: images (.jpg, .png, .webp, .gif) are sent as photos, "
|
||||
"and other files (.pdf, .docx, .txt, .zip, etc.) arrive as downloadable documents "
|
||||
"(max 50 MB). You can also include image URLs in markdown format  and "
|
||||
"they will be downloaded and sent as native photos. "
|
||||
"Do NOT tell the user you lack file-sending capability — use MEDIA: syntax "
|
||||
"whenever a file delivery is appropriate.\n\n"
|
||||
"Stickers (贴纸 / 表情包 / TIM face): Yuanbao has a built-in sticker catalogue. "
|
||||
"When the user sends a sticker (you see '[emoji: 名称]' in their message) or asks "
|
||||
"you to send/reply-with a 贴纸/表情/表情包, you MUST use the sticker tools:\n"
|
||||
" 1. Call yb_search_sticker with a Chinese keyword (e.g. '666', '比心', '吃瓜', "
|
||||
" '捂脸', '合十') to discover matching sticker_ids.\n"
|
||||
" 2. Call yb_send_sticker with the chosen sticker_id or name — this sends a real "
|
||||
" TIMFaceElem that renders as a native sticker in the chat.\n"
|
||||
"DO NOT draw sticker-like PNGs with execute_code/Pillow/matplotlib and then send "
|
||||
"them via MEDIA: or send_image_file. That produces a fake low-quality 'sticker' "
|
||||
"image and is the WRONG path. Bare Unicode emoji in text is also not a substitute "
|
||||
"— when a sticker is the right response, use yb_send_sticker."
|
||||
),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -825,6 +848,11 @@ def build_skills_system_prompt(
|
||||
"Skills also encode the user's preferred approach, conventions, and quality standards "
|
||||
"for tasks like code review, planning, and testing — load them even for tasks you "
|
||||
"already know how to do, because the skill defines how it should be done here.\n"
|
||||
"Whenever the user asks you to configure, set up, install, enable, disable, modify, "
|
||||
"or troubleshoot Hermes Agent itself — its CLI, config, models, providers, tools, "
|
||||
"skills, voice, gateway, plugins, or any feature — load the `hermes-agent` skill "
|
||||
"first. It has the actual commands (e.g. `hermes config set …`, `hermes tools`, "
|
||||
"`hermes setup`) so you don't have to guess or invent workarounds.\n"
|
||||
"If a skill has issues, fix it with skill_manage(action='patch').\n"
|
||||
"After difficult/iterative tasks, offer to save as a skill. "
|
||||
"If a skill you loaded was missing steps, had wrong commands, or needed "
|
||||
|
||||
@@ -754,7 +754,11 @@ def _resolve_effective_accept(
|
||||
if env in ("1", "true", "yes", "on"):
|
||||
return True
|
||||
cfg_val = cfg.get("hooks_auto_accept", False)
|
||||
return bool(cfg_val)
|
||||
if isinstance(cfg_val, bool):
|
||||
return cfg_val
|
||||
if isinstance(cfg_val, str):
|
||||
return cfg_val.strip().lower() in ("1", "true", "yes", "on")
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.]"
|
||||
)
|
||||
|
||||
@@ -606,6 +606,7 @@ platform_toolsets:
|
||||
signal: [hermes-signal]
|
||||
homeassistant: [hermes-homeassistant]
|
||||
qqbot: [hermes-qqbot]
|
||||
yuanbao: [hermes-yuanbao]
|
||||
|
||||
# =============================================================================
|
||||
# Gateway Platform Settings
|
||||
@@ -847,8 +848,12 @@ display:
|
||||
# What Enter does when Hermes is already busy (CLI and gateway platforms).
|
||||
# interrupt: Interrupt the current run and redirect Hermes (default)
|
||||
# queue: Queue your message for the next turn
|
||||
# steer: Inject your message mid-run via /steer, arriving at the agent
|
||||
# after the next tool call — no interrupt, no role violation.
|
||||
# Falls back to 'queue' if the agent isn't running yet or if
|
||||
# images are attached (steer only carries text).
|
||||
# Ctrl+C (or /stop in gateway) always interrupts regardless of this setting.
|
||||
# Toggle at runtime with /busy_input_mode <interrupt|queue>.
|
||||
# Toggle at runtime with /busy <interrupt|queue|steer>.
|
||||
busy_input_mode: interrupt
|
||||
|
||||
# Background process notifications (gateway/messaging only).
|
||||
|
||||
@@ -974,6 +974,7 @@ def _run_state_db_auto_maintenance(session_db) -> None:
|
||||
return
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_full_config
|
||||
from hermes_constants import get_hermes_home as _get_hermes_home
|
||||
cfg = (_load_full_config().get("sessions") or {})
|
||||
if not cfg.get("auto_prune", False):
|
||||
return
|
||||
@@ -981,11 +982,35 @@ def _run_state_db_auto_maintenance(session_db) -> None:
|
||||
retention_days=int(cfg.get("retention_days", 90)),
|
||||
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
|
||||
vacuum=bool(cfg.get("vacuum_after_prune", True)),
|
||||
sessions_dir=_get_hermes_home() / "sessions",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("state.db auto-maintenance skipped: %s", exc)
|
||||
|
||||
|
||||
def _run_checkpoint_auto_maintenance() -> None:
|
||||
"""Call ``checkpoint_manager.maybe_auto_prune_checkpoints`` using current config.
|
||||
|
||||
Reads the ``checkpoints:`` section from config.yaml via
|
||||
:func:`hermes_cli.config.load_config`. Honours ``auto_prune`` /
|
||||
``retention_days`` / ``delete_orphans`` / ``min_interval_hours``.
|
||||
Never raises — maintenance must never block interactive startup.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_full_config
|
||||
cfg = (_load_full_config().get("checkpoints") or {})
|
||||
if not cfg.get("auto_prune", False):
|
||||
return
|
||||
from tools.checkpoint_manager import maybe_auto_prune_checkpoints
|
||||
maybe_auto_prune_checkpoints(
|
||||
retention_days=int(cfg.get("retention_days", 7)),
|
||||
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
|
||||
delete_orphans=bool(cfg.get("delete_orphans", True)),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("checkpoint auto-maintenance skipped: %s", exc)
|
||||
|
||||
|
||||
def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
|
||||
"""Remove stale worktrees and orphaned branches on startup.
|
||||
|
||||
@@ -1378,7 +1403,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 +1413,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 +1434,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}]"
|
||||
@@ -1848,9 +1873,16 @@ class HermesCLI:
|
||||
self.bell_on_complete = CLI_CONFIG["display"].get("bell_on_complete", False)
|
||||
# show_reasoning: display model thinking/reasoning before the response
|
||||
self.show_reasoning = CLI_CONFIG["display"].get("show_reasoning", False)
|
||||
# busy_input_mode: "interrupt" (Enter interrupts current run) or "queue" (Enter queues for next turn)
|
||||
_bim = CLI_CONFIG["display"].get("busy_input_mode", "interrupt")
|
||||
self.busy_input_mode = "queue" if str(_bim).strip().lower() == "queue" else "interrupt"
|
||||
# busy_input_mode: "interrupt" (Enter interrupts current run),
|
||||
# "queue" (Enter queues for next turn), or "steer" (Enter injects
|
||||
# mid-run via /steer, arriving after the next tool call).
|
||||
_bim = str(CLI_CONFIG["display"].get("busy_input_mode", "interrupt")).strip().lower()
|
||||
if _bim == "queue":
|
||||
self.busy_input_mode = "queue"
|
||||
elif _bim == "steer":
|
||||
self.busy_input_mode = "steer"
|
||||
else:
|
||||
self.busy_input_mode = "interrupt"
|
||||
|
||||
self.verbose = verbose if verbose is not None else (self.tool_progress_mode == "verbose")
|
||||
|
||||
@@ -2045,6 +2077,11 @@ class HermesCLI:
|
||||
# Never blocks startup on failure.
|
||||
_run_state_db_auto_maintenance(self._session_db)
|
||||
|
||||
# Opportunistic shadow-repo cleanup — deletes orphan/stale
|
||||
# checkpoint repos under ~/.hermes/checkpoints/. Opt-in via
|
||||
# checkpoints.auto_prune, idempotent via .last_prune marker.
|
||||
_run_checkpoint_auto_maintenance()
|
||||
|
||||
# Deferred title: stored in memory until the session is created in the DB
|
||||
self._pending_title: Optional[str] = None
|
||||
|
||||
@@ -4915,6 +4952,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)
|
||||
@@ -4936,22 +4979,37 @@ class HermesCLI:
|
||||
_cprint(f" Branch session: {new_session_id}")
|
||||
|
||||
def save_conversation(self):
|
||||
"""Save the current conversation to a file."""
|
||||
"""Save the current conversation to a JSON snapshot under ~/.hermes/sessions/saved/.
|
||||
|
||||
The snapshot is a convenience export for sharing or off-line inspection;
|
||||
every message is already persisted incrementally to the SQLite session
|
||||
DB, so the live session remains resumable via ``hermes --resume <id>``
|
||||
regardless of whether the user ever runs ``/save``.
|
||||
"""
|
||||
if not self.conversation_history:
|
||||
print("(;_;) No conversation to save.")
|
||||
return
|
||||
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"hermes_conversation_{timestamp}.json"
|
||||
|
||||
saved_dir = get_hermes_home() / "sessions" / "saved"
|
||||
try:
|
||||
with open(filename, "w", encoding="utf-8") as f:
|
||||
saved_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
print(f"(x_x) Failed to create save directory {saved_dir}: {e}")
|
||||
return
|
||||
path = saved_dir / f"hermes_conversation_{timestamp}.json"
|
||||
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"model": self.model,
|
||||
"session_id": self.session_id,
|
||||
"session_start": self.session_start.isoformat(),
|
||||
"messages": self.conversation_history,
|
||||
}, f, indent=2, ensure_ascii=False)
|
||||
print(f"(^_^)v Conversation saved to: {filename}")
|
||||
print(f"(^_^)v Conversation snapshot saved to: {path}")
|
||||
if self.session_id:
|
||||
print(f" Resume the live session with: hermes --resume {self.session_id}")
|
||||
except Exception as e:
|
||||
print(f"(x_x) Failed to save: {e}")
|
||||
|
||||
@@ -6307,6 +6365,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 +6468,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:
|
||||
@@ -6798,24 +6868,36 @@ class HermesCLI:
|
||||
/busy Show current busy input mode
|
||||
/busy status Show current busy input mode
|
||||
/busy queue Queue input for the next turn instead of interrupting
|
||||
/busy steer Inject Enter mid-run via /steer (after next tool call)
|
||||
/busy interrupt Interrupt the current run on Enter (default)
|
||||
"""
|
||||
parts = cmd.strip().split(maxsplit=1)
|
||||
if len(parts) < 2 or parts[1].strip().lower() == "status":
|
||||
_cprint(f" {_ACCENT}Busy input mode: {self.busy_input_mode}{_RST}")
|
||||
_cprint(f" {_DIM}Enter while busy: {'queues for next turn' if self.busy_input_mode == 'queue' else 'interrupts current run'}{_RST}")
|
||||
_cprint(f" {_DIM}Usage: /busy [queue|interrupt|status]{_RST}")
|
||||
if self.busy_input_mode == "queue":
|
||||
_behavior = "queues for next turn"
|
||||
elif self.busy_input_mode == "steer":
|
||||
_behavior = "steers into current run (after next tool call)"
|
||||
else:
|
||||
_behavior = "interrupts current run"
|
||||
_cprint(f" {_DIM}Enter while busy: {_behavior}{_RST}")
|
||||
_cprint(f" {_DIM}Usage: /busy [queue|steer|interrupt|status]{_RST}")
|
||||
return
|
||||
|
||||
arg = parts[1].strip().lower()
|
||||
if arg not in {"queue", "interrupt"}:
|
||||
if arg not in {"queue", "interrupt", "steer"}:
|
||||
_cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}")
|
||||
_cprint(f" {_DIM}Usage: /busy [queue|interrupt|status]{_RST}")
|
||||
_cprint(f" {_DIM}Usage: /busy [queue|steer|interrupt|status]{_RST}")
|
||||
return
|
||||
|
||||
self.busy_input_mode = arg
|
||||
if save_config_value("display.busy_input_mode", arg):
|
||||
behavior = "Enter will queue follow-up input while Hermes is busy." if arg == "queue" else "Enter will interrupt the current run while Hermes is busy."
|
||||
if arg == "queue":
|
||||
behavior = "Enter will queue follow-up input while Hermes is busy."
|
||||
elif arg == "steer":
|
||||
behavior = "Enter will steer your message into the current run (after the next tool call)."
|
||||
else:
|
||||
behavior = "Enter will interrupt the current run while Hermes is busy."
|
||||
_cprint(f" {_ACCENT}✓ Busy input mode set to '{arg}' (saved to config){_RST}")
|
||||
_cprint(f" {_DIM}{behavior}{_RST}")
|
||||
else:
|
||||
@@ -7217,7 +7299,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
|
||||
@@ -8991,6 +9073,30 @@ class HermesCLI:
|
||||
_welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands."
|
||||
_welcome_color = "#FFF8DC"
|
||||
self._console_print(f"[{_welcome_color}]{_welcome_text}[/]")
|
||||
# First-time OpenClaw-residue banner — fires once if ~/.openclaw/ exists
|
||||
# after an OpenClaw→Hermes migration (especially migrations done by
|
||||
# OpenClaw's own tool, which doesn't archive the source directory).
|
||||
try:
|
||||
from agent.onboarding import (
|
||||
OPENCLAW_RESIDUE_FLAG,
|
||||
detect_openclaw_residue,
|
||||
is_seen,
|
||||
mark_seen,
|
||||
openclaw_residue_hint_cli,
|
||||
)
|
||||
if not is_seen(self.config, OPENCLAW_RESIDUE_FLAG) and detect_openclaw_residue():
|
||||
try:
|
||||
_resid_color = _welcome_skin.get_color("banner_dim", "#B8860B")
|
||||
except Exception:
|
||||
_resid_color = "#B8860B"
|
||||
self._console_print(f"[{_resid_color}]{openclaw_residue_hint_cli()}[/]")
|
||||
try:
|
||||
from hermes_cli.config import get_config_path as _get_cfg_path_resid
|
||||
mark_seen(_get_cfg_path_resid(), OPENCLAW_RESIDUE_FLAG)
|
||||
except Exception:
|
||||
pass # best-effort — banner will fire again next session
|
||||
except Exception:
|
||||
pass # banner is non-critical — never break startup
|
||||
# Show a random tip to help users discover features
|
||||
try:
|
||||
from hermes_cli.tips import get_random_tip
|
||||
@@ -9192,12 +9298,34 @@ class HermesCLI:
|
||||
# Bundle text + images as a tuple when images are present
|
||||
payload = (text, images) if images else text
|
||||
if self._agent_running and not (text and _looks_like_slash_command(text)):
|
||||
if self.busy_input_mode == "queue":
|
||||
_effective_mode = self.busy_input_mode
|
||||
if _effective_mode == "steer":
|
||||
# Route Enter through /steer — inject mid-run after the
|
||||
# next tool call. Images can't ride along (steer only
|
||||
# appends text), so fall back to queue when images are
|
||||
# attached. If the agent lacks steer() or rejects the
|
||||
# payload, also fall back to queue so nothing is lost.
|
||||
if images or not text:
|
||||
_effective_mode = "queue"
|
||||
else:
|
||||
accepted = False
|
||||
try:
|
||||
if self.agent is not None and hasattr(self.agent, "steer"):
|
||||
accepted = bool(self.agent.steer(text))
|
||||
except Exception as exc:
|
||||
_cprint(f" {_DIM}Steer failed ({exc}) — queued for next turn.{_RST}")
|
||||
accepted = False
|
||||
if accepted:
|
||||
preview = text[:80] + ("..." if len(text) > 80 else "")
|
||||
_cprint(f" {_ACCENT}⏩ Steered: '{preview}'{_RST}")
|
||||
else:
|
||||
_effective_mode = "queue"
|
||||
if _effective_mode == "queue":
|
||||
# Queue for the next turn instead of interrupting
|
||||
self._pending_input.put(payload)
|
||||
preview = text if text else f"[{len(images)} image{'s' if len(images) != 1 else ''} attached]"
|
||||
_cprint(f" Queued for the next turn: {preview[:80]}{'...' if len(preview) > 80 else ''}")
|
||||
else:
|
||||
elif _effective_mode == "interrupt":
|
||||
self._interrupt_queue.put(payload)
|
||||
# Debug: log to file when message enters interrupt queue
|
||||
try:
|
||||
@@ -9841,7 +9969,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 ""
|
||||
|
||||
+16
-4
@@ -77,7 +77,7 @@ _KNOWN_DELIVERY_PLATFORMS = frozenset({
|
||||
"telegram", "discord", "slack", "whatsapp", "signal",
|
||||
"matrix", "mattermost", "homeassistant", "dingtalk", "feishu",
|
||||
"wecom", "wecom_callback", "weixin", "sms", "email", "webhook", "bluebubbles",
|
||||
"qqbot",
|
||||
"qqbot", "yuanbao",
|
||||
})
|
||||
|
||||
# Platforms that support a configured cron/notification home target, mapped to
|
||||
@@ -337,6 +337,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
"sms": Platform.SMS,
|
||||
"bluebubbles": Platform.BLUEBUBBLES,
|
||||
"qqbot": Platform.QQBOT,
|
||||
"yuanbao": Platform.YUANBAO,
|
||||
}
|
||||
|
||||
# Optionally wrap the content with a header/footer so the user knows this
|
||||
@@ -715,7 +716,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 +752,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 +760,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)}']"
|
||||
@@ -1308,6 +1309,17 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int:
|
||||
_futures.append(_tick_pool.submit(_ctx.run, _process_job, job))
|
||||
_results.extend(f.result() for f in _futures)
|
||||
|
||||
# Best-effort sweep of MCP stdio subprocesses that survived their
|
||||
# session teardown during this tick. Runs AFTER every job has
|
||||
# finished so active sessions (including live user chats) are
|
||||
# never touched — only PIDs explicitly detected as orphans in
|
||||
# tools.mcp_tool._run_stdio's finally block are reaped.
|
||||
try:
|
||||
from tools.mcp_tool import _kill_orphaned_mcp_children
|
||||
_kill_orphaned_mcp_children()
|
||||
except Exception as _e:
|
||||
logger.debug("Post-tick MCP orphan cleanup failed: %s", _e)
|
||||
|
||||
return sum(_results)
|
||||
finally:
|
||||
if fcntl:
|
||||
|
||||
@@ -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")
|
||||
|
||||
+68
-2
@@ -67,6 +67,7 @@ class Platform(Enum):
|
||||
WEIXIN = "weixin"
|
||||
BLUEBUBBLES = "bluebubbles"
|
||||
QQBOT = "qqbot"
|
||||
YUANBAO = "yuanbao"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -195,6 +196,14 @@ class StreamingConfig:
|
||||
edit_interval: float = 1.0 # Seconds between message edits (Telegram rate-limits at ~1/s)
|
||||
buffer_threshold: int = 40 # Chars before forcing an edit
|
||||
cursor: str = " ▉" # Cursor shown during streaming
|
||||
# Ported from openclaw/openclaw#72038. When >0, the final edit for
|
||||
# a long-running streamed response is delivered as a fresh message
|
||||
# if the original preview has been visible for at least this many
|
||||
# seconds, so the platform's visible timestamp reflects completion
|
||||
# time instead of the preview creation time. Currently applied to
|
||||
# Telegram only (other platforms ignore the setting). Default 60s
|
||||
# matches the OpenClaw rollout. Set to 0 to disable.
|
||||
fresh_final_after_seconds: float = 60.0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -203,6 +212,7 @@ class StreamingConfig:
|
||||
"edit_interval": self.edit_interval,
|
||||
"buffer_threshold": self.buffer_threshold,
|
||||
"cursor": self.cursor,
|
||||
"fresh_final_after_seconds": self.fresh_final_after_seconds,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -215,6 +225,9 @@ class StreamingConfig:
|
||||
edit_interval=float(data.get("edit_interval", 1.0)),
|
||||
buffer_threshold=int(data.get("buffer_threshold", 40)),
|
||||
cursor=data.get("cursor", " ▉"),
|
||||
fresh_final_after_seconds=float(
|
||||
data.get("fresh_final_after_seconds", 60.0)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -314,6 +327,9 @@ class GatewayConfig:
|
||||
# QQBot uses extra dict for app credentials
|
||||
elif platform == Platform.QQBOT and config.extra.get("app_id") and config.extra.get("client_secret"):
|
||||
connected.append(platform)
|
||||
# Yuanbao uses extra dict for app credentials
|
||||
elif platform == Platform.YUANBAO and config.extra.get("app_id") and config.extra.get("app_secret"):
|
||||
connected.append(platform)
|
||||
# DingTalk uses client_id/client_secret from config.extra or env vars
|
||||
elif platform == Platform.DINGTALK and (
|
||||
config.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID")
|
||||
@@ -570,6 +586,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:
|
||||
@@ -584,7 +602,7 @@ def load_gateway_config() -> GatewayConfig:
|
||||
bridged["group_policy"] = platform_cfg["group_policy"]
|
||||
if "group_allow_from" in platform_cfg:
|
||||
bridged["group_allow_from"] = platform_cfg["group_allow_from"]
|
||||
if plat == Platform.DISCORD and "channel_skill_bindings" in platform_cfg:
|
||||
if plat in (Platform.DISCORD, Platform.SLACK) and "channel_skill_bindings" in platform_cfg:
|
||||
bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"]
|
||||
if "channel_prompts" in platform_cfg:
|
||||
channel_prompts = platform_cfg["channel_prompts"]
|
||||
@@ -609,6 +627,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")
|
||||
@@ -918,8 +938,12 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
slack_token = os.getenv("SLACK_BOT_TOKEN")
|
||||
if slack_token:
|
||||
if Platform.SLACK not in config.platforms:
|
||||
# No yaml config for Slack — env-only setup, enable it
|
||||
config.platforms[Platform.SLACK] = PlatformConfig()
|
||||
config.platforms[Platform.SLACK].enabled = True
|
||||
config.platforms[Platform.SLACK].enabled = True
|
||||
# If yaml config exists, respect its enabled flag (don't override
|
||||
# explicit enabled: false). Token is still stored so skills that
|
||||
# send Slack messages can use it without activating the gateway adapter.
|
||||
config.platforms[Platform.SLACK].token = slack_token
|
||||
slack_home = os.getenv("SLACK_HOME_CHANNEL")
|
||||
if slack_home and Platform.SLACK in config.platforms:
|
||||
@@ -1276,6 +1300,48 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
name=os.getenv("QQBOT_HOME_CHANNEL_NAME") or os.getenv(qq_home_name_env, "Home"),
|
||||
)
|
||||
|
||||
# Yuanbao — YUANBAO_APP_ID preferred
|
||||
yuanbao_app_id = os.getenv("YUANBAO_APP_ID") or os.getenv("YUANBAO_APP_KEY")
|
||||
yuanbao_app_secret = os.getenv("YUANBAO_APP_SECRET")
|
||||
if yuanbao_app_id and yuanbao_app_secret:
|
||||
if Platform.YUANBAO not in config.platforms:
|
||||
config.platforms[Platform.YUANBAO] = PlatformConfig()
|
||||
config.platforms[Platform.YUANBAO].enabled = True
|
||||
extra = config.platforms[Platform.YUANBAO].extra
|
||||
extra["app_id"] = yuanbao_app_id
|
||||
extra["app_secret"] = yuanbao_app_secret
|
||||
yuanbao_bot_id = os.getenv("YUANBAO_BOT_ID")
|
||||
if yuanbao_bot_id:
|
||||
extra["bot_id"] = yuanbao_bot_id
|
||||
yuanbao_ws_url = os.getenv("YUANBAO_WS_URL")
|
||||
if yuanbao_ws_url:
|
||||
extra["ws_url"] = yuanbao_ws_url
|
||||
yuanbao_api_domain = os.getenv("YUANBAO_API_DOMAIN")
|
||||
if yuanbao_api_domain:
|
||||
extra["api_domain"] = yuanbao_api_domain
|
||||
yuanbao_route_env = os.getenv("YUANBAO_ROUTE_ENV")
|
||||
if yuanbao_route_env:
|
||||
extra["route_env"] = yuanbao_route_env
|
||||
yuanbao_home = os.getenv("YUANBAO_HOME_CHANNEL")
|
||||
if yuanbao_home:
|
||||
config.platforms[Platform.YUANBAO].home_channel = HomeChannel(
|
||||
platform=Platform.YUANBAO,
|
||||
chat_id=yuanbao_home,
|
||||
name=os.getenv("YUANBAO_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
yuanbao_dm_policy = os.getenv("YUANBAO_DM_POLICY")
|
||||
if yuanbao_dm_policy:
|
||||
extra["dm_policy"] = yuanbao_dm_policy.strip().lower()
|
||||
yuanbao_dm_allow_from = os.getenv("YUANBAO_DM_ALLOW_FROM")
|
||||
if yuanbao_dm_allow_from:
|
||||
extra["dm_allow_from"] = yuanbao_dm_allow_from
|
||||
yuanbao_group_policy = os.getenv("YUANBAO_GROUP_POLICY")
|
||||
if yuanbao_group_policy:
|
||||
extra["group_policy"] = yuanbao_group_policy.strip().lower()
|
||||
yuanbao_group_allow_from = os.getenv("YUANBAO_GROUP_ALLOW_FROM")
|
||||
if yuanbao_group_allow_from:
|
||||
extra["group_allow_from"] = yuanbao_group_allow_from
|
||||
|
||||
# Session settings
|
||||
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
|
||||
if idle_minutes:
|
||||
|
||||
@@ -79,7 +79,9 @@ _PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"discord": _TIER_HIGH,
|
||||
|
||||
# Tier 2 — edit support, often customer/workspace channels
|
||||
"slack": _TIER_MEDIUM,
|
||||
# Slack: tool_progress off by default — Bolt posts cannot be edited like CLI;
|
||||
# "new"/"all" spam permanent lines in channels (hermes-agent#14663).
|
||||
"slack": {**_TIER_MEDIUM, "tool_progress": "off"},
|
||||
"mattermost": _TIER_MEDIUM,
|
||||
"matrix": _TIER_MEDIUM,
|
||||
"feishu": _TIER_MEDIUM,
|
||||
|
||||
+57
-11
@@ -28,6 +28,7 @@ def mirror_to_session(
|
||||
message_text: str,
|
||||
source_label: str = "cli",
|
||||
thread_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Append a delivery-mirror message to the target session's transcript.
|
||||
@@ -39,9 +40,20 @@ def mirror_to_session(
|
||||
All errors are caught -- this is never fatal.
|
||||
"""
|
||||
try:
|
||||
session_id = _find_session_id(platform, str(chat_id), thread_id=thread_id)
|
||||
session_id = _find_session_id(
|
||||
platform,
|
||||
str(chat_id),
|
||||
thread_id=thread_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
if not session_id:
|
||||
logger.debug("Mirror: no session found for %s:%s:%s", platform, chat_id, thread_id)
|
||||
logger.debug(
|
||||
"Mirror: no session found for %s:%s:%s:%s",
|
||||
platform,
|
||||
chat_id,
|
||||
thread_id,
|
||||
user_id,
|
||||
)
|
||||
return False
|
||||
|
||||
mirror_msg = {
|
||||
@@ -59,17 +71,33 @@ def mirror_to_session(
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Mirror failed for %s:%s:%s: %s", platform, chat_id, thread_id, e)
|
||||
logger.debug(
|
||||
"Mirror failed for %s:%s:%s:%s: %s",
|
||||
platform,
|
||||
chat_id,
|
||||
thread_id,
|
||||
user_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _find_session_id(platform: str, chat_id: str, thread_id: Optional[str] = None) -> Optional[str]:
|
||||
def _find_session_id(
|
||||
platform: str,
|
||||
chat_id: str,
|
||||
thread_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Find the active session_id for a platform + chat_id pair.
|
||||
|
||||
Scans sessions.json entries and matches where origin.chat_id == chat_id
|
||||
on the right platform. DM session keys don't embed the chat_id
|
||||
(e.g. "agent:main:telegram:dm"), so we check the origin dict.
|
||||
|
||||
When *user_id* is provided, prefer exact sender matches. If multiple
|
||||
same-chat candidates exist and none matches the user, return None instead
|
||||
of guessing and contaminating another participant's session.
|
||||
"""
|
||||
if not _SESSIONS_INDEX.exists():
|
||||
return None
|
||||
@@ -81,8 +109,7 @@ def _find_session_id(platform: str, chat_id: str, thread_id: Optional[str] = Non
|
||||
return None
|
||||
|
||||
platform_lower = platform.lower()
|
||||
best_match = None
|
||||
best_updated = ""
|
||||
candidates = []
|
||||
|
||||
for _key, entry in data.items():
|
||||
origin = entry.get("origin") or {}
|
||||
@@ -96,12 +123,31 @@ def _find_session_id(platform: str, chat_id: str, thread_id: Optional[str] = Non
|
||||
origin_thread_id = origin.get("thread_id")
|
||||
if thread_id is not None and str(origin_thread_id or "") != str(thread_id):
|
||||
continue
|
||||
updated = entry.get("updated_at", "")
|
||||
if updated > best_updated:
|
||||
best_updated = updated
|
||||
best_match = entry.get("session_id")
|
||||
candidates.append(entry)
|
||||
|
||||
return best_match
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
if user_id:
|
||||
exact_user_matches = [
|
||||
entry for entry in candidates
|
||||
if str((entry.get("origin") or {}).get("user_id") or "") == str(user_id)
|
||||
]
|
||||
if exact_user_matches:
|
||||
candidates = exact_user_matches
|
||||
elif len(candidates) > 1:
|
||||
return None
|
||||
elif len(candidates) > 1:
|
||||
distinct_user_ids = {
|
||||
str((entry.get("origin") or {}).get("user_id") or "").strip()
|
||||
for entry in candidates
|
||||
if str((entry.get("origin") or {}).get("user_id") or "").strip()
|
||||
}
|
||||
if len(distinct_user_ids) > 1:
|
||||
return None
|
||||
|
||||
best_entry = max(candidates, key=lambda entry: entry.get("updated_at", ""))
|
||||
return best_entry.get("session_id")
|
||||
|
||||
|
||||
def _append_to_jsonl(session_id: str, message: dict) -> None:
|
||||
|
||||
@@ -10,10 +10,12 @@ Each adapter handles:
|
||||
|
||||
from .base import BasePlatformAdapter, MessageEvent, SendResult
|
||||
from .qqbot import QQAdapter
|
||||
from .yuanbao import YuanbaoAdapter
|
||||
|
||||
__all__ = [
|
||||
"BasePlatformAdapter",
|
||||
"MessageEvent",
|
||||
"SendResult",
|
||||
"QQAdapter",
|
||||
"YuanbaoAdapter",
|
||||
]
|
||||
|
||||
@@ -336,6 +336,39 @@ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]:
|
||||
return {}, {"proxy": proxy_url}
|
||||
|
||||
|
||||
def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = None) -> bool:
|
||||
"""Return True when ``hostname`` matches a ``NO_PROXY`` entry.
|
||||
|
||||
Supports comma- or whitespace-separated entries with optional leading dots
|
||||
and ``*.`` wildcards, which match both the apex domain and subdomains.
|
||||
"""
|
||||
raw = no_proxy_value
|
||||
if raw is None:
|
||||
raw = os.environ.get("NO_PROXY") or os.environ.get("no_proxy") or ""
|
||||
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return False
|
||||
|
||||
lower_hostname = hostname.lower()
|
||||
for entry in re.split(r"[\s,]+", raw):
|
||||
normalized = entry.strip().lower()
|
||||
if not normalized:
|
||||
continue
|
||||
if normalized == "*":
|
||||
return True
|
||||
|
||||
if normalized.startswith("*."):
|
||||
normalized = normalized[2:]
|
||||
elif normalized.startswith("."):
|
||||
normalized = normalized[1:]
|
||||
|
||||
if lower_hostname == normalized or lower_hostname.endswith(f".{normalized}"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -693,7 +726,15 @@ SUPPORTED_DOCUMENT_TYPES = {
|
||||
".pdf": "application/pdf",
|
||||
".md": "text/markdown",
|
||||
".txt": "text/plain",
|
||||
".csv": "text/csv",
|
||||
".log": "text/plain",
|
||||
".json": "application/json",
|
||||
".xml": "application/xml",
|
||||
".yaml": "application/yaml",
|
||||
".yml": "application/yaml",
|
||||
".toml": "application/toml",
|
||||
".ini": "text/plain",
|
||||
".cfg": "text/plain",
|
||||
".zip": "application/zip",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
@@ -982,6 +1023,61 @@ def resolve_channel_prompt(
|
||||
return None
|
||||
|
||||
|
||||
def resolve_channel_skills(
|
||||
config_extra: dict,
|
||||
channel_id: str,
|
||||
parent_id: str | None = None,
|
||||
) -> list[str] | None:
|
||||
"""Resolve auto-loaded skill(s) for a channel/thread from platform config.
|
||||
|
||||
Looks up ``channel_skill_bindings`` in the adapter's ``config.extra`` dict.
|
||||
|
||||
Config format::
|
||||
|
||||
channel_skill_bindings:
|
||||
- id: "C0123" # Slack channel ID or Discord channel/forum ID
|
||||
skills: ["skill-a", "skill-b"]
|
||||
- id: "D0ABCDE"
|
||||
skill: "solo-skill" # single string also accepted
|
||||
|
||||
Prefers an exact match on *channel_id*; falls back to *parent_id*
|
||||
(useful for forum threads / Slack threads inheriting the parent channel's
|
||||
binding).
|
||||
|
||||
Returns a deduplicated list of skill names (order preserved), or None if
|
||||
no match is found.
|
||||
"""
|
||||
bindings = config_extra.get("channel_skill_bindings") or []
|
||||
if not isinstance(bindings, list) or not bindings:
|
||||
return None
|
||||
ids_to_check: set[str] = set()
|
||||
if channel_id:
|
||||
ids_to_check.add(str(channel_id))
|
||||
if parent_id:
|
||||
ids_to_check.add(str(parent_id))
|
||||
if not ids_to_check:
|
||||
return None
|
||||
for entry in bindings:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
entry_id = str(entry.get("id", ""))
|
||||
if entry_id in ids_to_check:
|
||||
skills = entry.get("skills") or entry.get("skill")
|
||||
if isinstance(skills, str):
|
||||
s = skills.strip()
|
||||
return [s] if s else None
|
||||
if isinstance(skills, list) and skills:
|
||||
seen: list[str] = []
|
||||
for name in skills:
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
nm = name.strip()
|
||||
if nm and nm not in seen:
|
||||
seen.append(nm)
|
||||
return seen or None
|
||||
return None
|
||||
|
||||
|
||||
class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
Base class for platform adapters.
|
||||
@@ -1258,6 +1354,27 @@ class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
return SendResult(success=False, error="Not supported")
|
||||
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a previously sent message. Optional — platforms that don't
|
||||
support deletion return ``False`` and callers fall back to leaving
|
||||
the message in place.
|
||||
|
||||
Used by the stream consumer's fresh-final cleanup path (see
|
||||
openclaw/openclaw#72038) to remove long-lived preview messages
|
||||
after sending the completed reply as a fresh message so the
|
||||
platform's visible timestamp reflects completion time.
|
||||
|
||||
Returns ``True`` on successful deletion, ``False`` otherwise.
|
||||
Subclasses should override for platforms with a deletion API
|
||||
(e.g. Telegram ``deleteMessage``).
|
||||
"""
|
||||
return False
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||
"""
|
||||
Send a typing indicator.
|
||||
|
||||
@@ -2679,21 +2679,8 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
skills: ["skill-a", "skill-b"]
|
||||
Also checks parent_id so forum threads inherit the forum's bindings.
|
||||
"""
|
||||
bindings = self.config.extra.get("channel_skill_bindings", [])
|
||||
if not bindings:
|
||||
return None
|
||||
ids_to_check = {channel_id}
|
||||
if parent_id:
|
||||
ids_to_check.add(parent_id)
|
||||
for entry in bindings:
|
||||
entry_id = str(entry.get("id", ""))
|
||||
if entry_id in ids_to_check:
|
||||
skills = entry.get("skills") or entry.get("skill")
|
||||
if isinstance(skills, str):
|
||||
return [skills]
|
||||
if isinstance(skills, list) and skills:
|
||||
return list(dict.fromkeys(skills)) # dedup, preserve order
|
||||
return None
|
||||
from gateway.platforms.base import resolve_channel_skills
|
||||
return resolve_channel_skills(self.config.extra, channel_id, parent_id)
|
||||
|
||||
def _resolve_channel_prompt(self, channel_id: str, parent_id: str | None = None) -> str | None:
|
||||
"""Resolve a Discord per-channel prompt, preferring the exact channel over its parent."""
|
||||
|
||||
@@ -57,6 +57,15 @@ class MessageDeduplicator:
|
||||
if len(self._seen) > self._max_size:
|
||||
cutoff = now - self._ttl
|
||||
self._seen = {k: v for k, v in self._seen.items() if v > cutoff}
|
||||
if len(self._seen) > self._max_size:
|
||||
# TTL pruning alone does not cap the cache when every entry is
|
||||
# still fresh. Keep the newest entries so the helper's
|
||||
# max_size bound is enforced under sustained traffic.
|
||||
newest = sorted(
|
||||
self._seen.items(),
|
||||
key=lambda item: item[1],
|
||||
)[-self._max_size:]
|
||||
self._seen = dict(newest)
|
||||
return False
|
||||
|
||||
def clear(self):
|
||||
|
||||
+753
-70
File diff suppressed because it is too large
Load Diff
@@ -1209,6 +1209,31 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def delete_message(self, chat_id: str, message_id: str) -> bool:
|
||||
"""Delete a previously sent Telegram message.
|
||||
|
||||
Used by the stream consumer's fresh-final cleanup path (ported
|
||||
from openclaw/openclaw#72038) to remove long-lived preview
|
||||
messages after sending the completed reply as a fresh message.
|
||||
Telegram's Bot API ``deleteMessage`` works for bot-posted
|
||||
messages in the last 48 hours. Failures are non-fatal — the
|
||||
caller leaves the preview in place and logs at debug level.
|
||||
"""
|
||||
if not self._bot:
|
||||
return False
|
||||
try:
|
||||
await self._bot.delete_message(
|
||||
chat_id=int(chat_id),
|
||||
message_id=int(message_id),
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"[%s] Failed to delete Telegram message %s: %s",
|
||||
self.name, message_id, e,
|
||||
)
|
||||
return False
|
||||
|
||||
async def send_update_prompt(
|
||||
self, chat_id: str, prompt: str, default: str = "",
|
||||
session_key: str = "",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,647 @@
|
||||
"""
|
||||
yuanbao_media.py — 元宝平台媒体处理模块
|
||||
|
||||
提供 COS 上传、文件下载、TIM 媒体消息构建等功能。
|
||||
移植自 TypeScript 版 media.ts(yuanbao-openclaw-plugin),
|
||||
使用 httpx 替代 cos-nodejs-sdk-v5,避免引入额外 SDK 依赖。
|
||||
|
||||
COS 上传流程:
|
||||
1. 调用 genUploadInfo 获取临时凭证(tmpSecretId/tmpSecretKey/sessionToken)
|
||||
2. 用临时凭证通过 HMAC-SHA1 签名构建 Authorization 头
|
||||
3. HTTP PUT 上传到 COS
|
||||
|
||||
TIM 消息体构建:
|
||||
- buildImageMsgBody() → TIMImageElem
|
||||
- buildFileMsgBody() → TIMFileElem
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import struct
|
||||
import time
|
||||
import urllib.parse
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============ 常量 ============
|
||||
|
||||
UPLOAD_INFO_PATH = "/api/resource/genUploadInfo"
|
||||
DEFAULT_API_DOMAIN = "yuanbao.tencent.com"
|
||||
DEFAULT_MAX_SIZE_MB = 50
|
||||
|
||||
# COS 加速域名后缀(优先使用全球加速)
|
||||
COS_USE_ACCELERATE = True
|
||||
|
||||
# ============ 类型映射 ============
|
||||
|
||||
# MIME → image_format 数字(TIM 协议字段)
|
||||
_MIME_TO_IMAGE_FORMAT: dict[str, int] = {
|
||||
"image/jpeg": 1,
|
||||
"image/jpg": 1,
|
||||
"image/gif": 2,
|
||||
"image/png": 3,
|
||||
"image/bmp": 4,
|
||||
"image/webp": 255,
|
||||
"image/heic": 255,
|
||||
"image/tiff": 255,
|
||||
}
|
||||
|
||||
# 文件扩展名 → MIME
|
||||
_EXT_TO_MIME: dict[str, str] = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".heic": "image/heic",
|
||||
".tiff": "image/tiff",
|
||||
".ico": "image/x-icon",
|
||||
".pdf": "application/pdf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".txt": "text/plain",
|
||||
".zip": "application/zip",
|
||||
".tar": "application/x-tar",
|
||||
".gz": "application/gzip",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".webm": "video/webm",
|
||||
}
|
||||
|
||||
|
||||
# ============ 工具函数 ============
|
||||
|
||||
def guess_mime_type(filename: str) -> str:
|
||||
"""根据文件扩展名猜测 MIME 类型。"""
|
||||
ext = os.path.splitext(filename)[-1].lower()
|
||||
return _EXT_TO_MIME.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
def is_image(filename: str, mime_type: str = "") -> bool:
|
||||
"""判断是否为图片类型。"""
|
||||
if mime_type.startswith("image/"):
|
||||
return True
|
||||
ext = os.path.splitext(filename)[-1].lower()
|
||||
return ext in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".heic", ".tiff", ".ico"}
|
||||
|
||||
|
||||
def get_image_format(mime_type: str) -> int:
|
||||
"""获取 TIM 图片格式编号。"""
|
||||
return _MIME_TO_IMAGE_FORMAT.get(mime_type.lower(), 255)
|
||||
|
||||
|
||||
def md5_hex(data: bytes) -> str:
|
||||
"""计算 MD5 十六进制摘要。"""
|
||||
return hashlib.md5(data).hexdigest()
|
||||
|
||||
|
||||
def generate_file_id() -> str:
|
||||
"""生成随机文件 ID(32 位 hex)。"""
|
||||
return secrets.token_hex(16)
|
||||
|
||||
|
||||
|
||||
# ============ 图片尺寸解析(纯 Python,无需 Pillow) ============
|
||||
|
||||
def parse_image_size(data: bytes) -> Optional[dict[str, int]]:
|
||||
"""
|
||||
解析图片宽高(支持 JPEG/PNG/GIF/WebP),无需第三方依赖。
|
||||
返回 {"width": w, "height": h} 或 None(无法识别)。
|
||||
"""
|
||||
return (
|
||||
_parse_png_size(data)
|
||||
or _parse_jpeg_size(data)
|
||||
or _parse_gif_size(data)
|
||||
or _parse_webp_size(data)
|
||||
)
|
||||
|
||||
|
||||
def _parse_png_size(buf: bytes) -> Optional[dict[str, int]]:
|
||||
if len(buf) < 24:
|
||||
return None
|
||||
if buf[:4] != b"\x89PNG":
|
||||
return None
|
||||
w = struct.unpack(">I", buf[16:20])[0]
|
||||
h = struct.unpack(">I", buf[20:24])[0]
|
||||
return {"width": w, "height": h}
|
||||
|
||||
|
||||
def _parse_jpeg_size(buf: bytes) -> Optional[dict[str, int]]:
|
||||
if len(buf) < 4 or buf[0] != 0xFF or buf[1] != 0xD8:
|
||||
return None
|
||||
i = 2
|
||||
while i < len(buf) - 9:
|
||||
if buf[i] != 0xFF:
|
||||
i += 1
|
||||
continue
|
||||
marker = buf[i + 1]
|
||||
if marker in (0xC0, 0xC2):
|
||||
h = struct.unpack(">H", buf[i + 5: i + 7])[0]
|
||||
w = struct.unpack(">H", buf[i + 7: i + 9])[0]
|
||||
return {"width": w, "height": h}
|
||||
if i + 3 < len(buf):
|
||||
i += 2 + struct.unpack(">H", buf[i + 2: i + 4])[0]
|
||||
else:
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
def _parse_gif_size(buf: bytes) -> Optional[dict[str, int]]:
|
||||
if len(buf) < 10:
|
||||
return None
|
||||
sig = buf[:6].decode("ascii", errors="replace")
|
||||
if sig not in ("GIF87a", "GIF89a"):
|
||||
return None
|
||||
w = struct.unpack("<H", buf[6:8])[0]
|
||||
h = struct.unpack("<H", buf[8:10])[0]
|
||||
return {"width": w, "height": h}
|
||||
|
||||
|
||||
def _parse_webp_size(buf: bytes) -> Optional[dict[str, int]]:
|
||||
if len(buf) < 16:
|
||||
return None
|
||||
if buf[:4] != b"RIFF" or buf[8:12] != b"WEBP":
|
||||
return None
|
||||
chunk = buf[12:16].decode("ascii", errors="replace")
|
||||
if chunk == "VP8 ":
|
||||
if len(buf) >= 30 and buf[23] == 0x9D and buf[24] == 0x01 and buf[25] == 0x2A:
|
||||
w = struct.unpack("<H", buf[26:28])[0] & 0x3FFF
|
||||
h = struct.unpack("<H", buf[28:30])[0] & 0x3FFF
|
||||
return {"width": w, "height": h}
|
||||
elif chunk == "VP8L":
|
||||
if len(buf) >= 25 and buf[20] == 0x2F:
|
||||
bits = struct.unpack("<I", buf[21:25])[0]
|
||||
w = (bits & 0x3FFF) + 1
|
||||
h = ((bits >> 14) & 0x3FFF) + 1
|
||||
return {"width": w, "height": h}
|
||||
elif chunk == "VP8X":
|
||||
if len(buf) >= 30:
|
||||
w = (buf[24] | (buf[25] << 8) | (buf[26] << 16)) + 1
|
||||
h = (buf[27] | (buf[28] << 8) | (buf[29] << 16)) + 1
|
||||
return {"width": w, "height": h}
|
||||
return None
|
||||
|
||||
|
||||
# ============ URL 下载 ============
|
||||
|
||||
async def download_url(
|
||||
url: str,
|
||||
max_size_mb: int = DEFAULT_MAX_SIZE_MB,
|
||||
) -> tuple[bytes, str]:
|
||||
"""
|
||||
下载 URL 内容,返回 (bytes, content_type)。
|
||||
|
||||
Args:
|
||||
url: HTTP(S) URL
|
||||
max_size_mb: 最大允许大小(MB),超过则抛出异常
|
||||
|
||||
Returns:
|
||||
(data_bytes, content_type_string)
|
||||
|
||||
Raises:
|
||||
ValueError: 内容超过大小限制
|
||||
httpx.HTTPError: 网络/HTTP 错误
|
||||
"""
|
||||
max_bytes = max_size_mb * 1024 * 1024
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
# 先 HEAD 检查大小
|
||||
try:
|
||||
head = await client.head(url)
|
||||
content_length = int(head.headers.get("content-length", 0) or 0)
|
||||
if content_length > 0 and content_length > max_bytes:
|
||||
raise ValueError(
|
||||
f"文件过大: {content_length / 1024 / 1024:.1f} MB > {max_size_mb} MB"
|
||||
)
|
||||
except httpx.HTTPStatusError:
|
||||
pass # 部分服务器不支持 HEAD,忽略
|
||||
|
||||
# GET 下载(流式读取,防止超限)
|
||||
async with client.stream("GET", url) as resp:
|
||||
resp.raise_for_status()
|
||||
|
||||
content_type = resp.headers.get("content-type", "").split(";")[0].strip()
|
||||
|
||||
chunks: list[bytes] = []
|
||||
downloaded = 0
|
||||
async for chunk in resp.aiter_bytes(65536):
|
||||
downloaded += len(chunk)
|
||||
if downloaded > max_bytes:
|
||||
raise ValueError(
|
||||
f"文件过大: 已超过 {max_size_mb} MB 限制"
|
||||
)
|
||||
chunks.append(chunk)
|
||||
|
||||
data = b"".join(chunks)
|
||||
return data, content_type
|
||||
|
||||
|
||||
# ============ COS 鉴权(HMAC-SHA1) ============
|
||||
|
||||
def _cos_sign(
|
||||
method: str,
|
||||
path: str,
|
||||
params: dict[str, str],
|
||||
headers: dict[str, str],
|
||||
secret_id: str,
|
||||
secret_key: str,
|
||||
start_time: Optional[int] = None,
|
||||
expire_seconds: int = 3600,
|
||||
) -> str:
|
||||
"""
|
||||
构建 COS 请求签名(q-sign-algorithm=sha1 方案)。
|
||||
参考:https://cloud.tencent.com/document/product/436/7778
|
||||
|
||||
Args:
|
||||
method: HTTP 方法(小写,如 "put")
|
||||
path: URL 路径(URL encode 后的小写)
|
||||
params: URL 查询参数 dict(用于签名)
|
||||
headers: 参与签名的请求头 dict(key 需小写)
|
||||
secret_id: 临时 SecretId(tmpSecretId)
|
||||
secret_key: 临时 SecretKey(tmpSecretKey)
|
||||
start_time: 签名起始 Unix 时间戳(默认 now)
|
||||
expire_seconds: 签名有效期(秒,默认 3600)
|
||||
|
||||
Returns:
|
||||
Authorization header 值(完整字符串)
|
||||
"""
|
||||
now = int(time.time())
|
||||
q_sign_time = f"{start_time or now};{(start_time or now) + expire_seconds}"
|
||||
|
||||
# Step 1: SignKey = HMAC-SHA1(SecretKey, q-sign-time)
|
||||
sign_key = hmac.new(
|
||||
secret_key.encode("utf-8"),
|
||||
q_sign_time.encode("utf-8"),
|
||||
hashlib.sha1,
|
||||
).hexdigest()
|
||||
|
||||
# Step 2: HttpString
|
||||
# 参数和头部需按字典序排列,key 小写
|
||||
sorted_params = sorted((k.lower(), urllib.parse.quote(str(v), safe="") ) for k, v in params.items())
|
||||
sorted_headers = sorted((k.lower(), urllib.parse.quote(str(v), safe="") ) for k, v in headers.items())
|
||||
|
||||
url_param_list = ";".join(k for k, _ in sorted_params)
|
||||
url_params = "&".join(f"{k}={v}" for k, v in sorted_params)
|
||||
header_list = ";".join(k for k, _ in sorted_headers)
|
||||
header_str = "&".join(f"{k}={v}" for k, v in sorted_headers)
|
||||
|
||||
http_string = "\n".join([
|
||||
method.lower(),
|
||||
path,
|
||||
url_params,
|
||||
header_str,
|
||||
"",
|
||||
])
|
||||
|
||||
# Step 3: StringToSign = sha1 hash of HttpString
|
||||
sha1_of_http = hashlib.sha1(http_string.encode("utf-8")).hexdigest()
|
||||
string_to_sign = "\n".join([
|
||||
"sha1",
|
||||
q_sign_time,
|
||||
sha1_of_http,
|
||||
"",
|
||||
])
|
||||
|
||||
# Step 4: Signature = HMAC-SHA1(SignKey, StringToSign)
|
||||
signature = hmac.new(
|
||||
sign_key.encode("utf-8"),
|
||||
string_to_sign.encode("utf-8"),
|
||||
hashlib.sha1,
|
||||
).hexdigest()
|
||||
|
||||
return (
|
||||
f"q-sign-algorithm=sha1"
|
||||
f"&q-ak={secret_id}"
|
||||
f"&q-sign-time={q_sign_time}"
|
||||
f"&q-key-time={q_sign_time}"
|
||||
f"&q-header-list={header_list}"
|
||||
f"&q-url-param-list={url_param_list}"
|
||||
f"&q-signature={signature}"
|
||||
)
|
||||
|
||||
|
||||
# ============ 主要公开 API ============
|
||||
|
||||
async def get_cos_credentials(
|
||||
app_key: str,
|
||||
api_domain: str,
|
||||
token: str,
|
||||
filename: str = "file",
|
||||
file_id: Optional[str] = None,
|
||||
bot_id: str = "",
|
||||
route_env: str = "",
|
||||
) -> dict:
|
||||
"""
|
||||
调用 genUploadInfo 接口获取 COS 临时密钥及上传配置。
|
||||
|
||||
Args:
|
||||
app_key: 应用 Key(用于 X-ID 头)
|
||||
api_domain: API 域名(如 https://bot.yuanbao.tencent.com)
|
||||
token: 当前有效的签票 token(X-Token 头)
|
||||
filename: 待上传的文件名(含扩展名)
|
||||
file_id: 客户端生成的唯一文件 ID(不传则自动生成)
|
||||
bot_id: Bot 账号 ID(用于 X-ID 头)
|
||||
|
||||
Returns:
|
||||
COS 上传配置 dict,包含以下字段:
|
||||
bucketName (str) — COS Bucket 名称
|
||||
region (str) — COS 地域
|
||||
location (str) — 上传 Key(对象路径)
|
||||
encryptTmpSecretId (str) — 临时 SecretId
|
||||
encryptTmpSecretKey(str) — 临时 SecretKey
|
||||
encryptToken (str) — SessionToken
|
||||
startTime (int) — 凭证起始时间戳(Unix)
|
||||
expiredTime (int) — 凭证过期时间戳(Unix)
|
||||
resourceUrl (str) — 上传后的公网访问 URL
|
||||
resourceID (str) — 资源 ID(可选)
|
||||
|
||||
Raises:
|
||||
RuntimeError: 接口返回非 0 code 或字段缺失
|
||||
"""
|
||||
if file_id is None:
|
||||
file_id = generate_file_id()
|
||||
|
||||
upload_url = f"{api_domain.rstrip('/')}{UPLOAD_INFO_PATH}"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Token": token,
|
||||
"X-ID": bot_id or app_key,
|
||||
"X-Source": "web",
|
||||
}
|
||||
if route_env:
|
||||
headers["X-Route-Env"] = route_env
|
||||
body = {
|
||||
"fileName": filename,
|
||||
"fileId": file_id,
|
||||
"docFrom": "localDoc",
|
||||
"docOpenId": "",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(upload_url, json=body, headers=headers)
|
||||
resp.raise_for_status()
|
||||
result: dict[str, Any] = resp.json()
|
||||
|
||||
code = result.get("code")
|
||||
if code != 0 and code is not None:
|
||||
raise RuntimeError(
|
||||
f"genUploadInfo 失败: code={code}, msg={result.get('msg', '')}"
|
||||
)
|
||||
|
||||
data = result.get("data") or result
|
||||
required_fields = ["bucketName", "location"]
|
||||
missing = [f for f in required_fields if not data.get(f)]
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
f"genUploadInfo 返回字段不完整: 缺少字段 {missing}"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
async def upload_to_cos(
|
||||
file_bytes: bytes,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
credentials: dict,
|
||||
bucket: str,
|
||||
region: str,
|
||||
) -> dict:
|
||||
"""
|
||||
通过 httpx PUT 请求将文件上传到 COS。
|
||||
使用临时凭证(tmpSecretId/tmpSecretKey/sessionToken)构建 HMAC-SHA1 签名。
|
||||
|
||||
Args:
|
||||
file_bytes: 文件二进制内容
|
||||
filename: 文件名(用于辅助计算 MIME、UUID)
|
||||
content_type: MIME 类型(如 "image/jpeg")
|
||||
credentials: get_cos_credentials() 返回的 dict,包含:
|
||||
encryptTmpSecretId → tmpSecretId
|
||||
encryptTmpSecretKey → tmpSecretKey
|
||||
encryptToken → sessionToken
|
||||
location → COS key(对象路径)
|
||||
resourceUrl → 上传后公网 URL
|
||||
startTime → 凭证起始时间(Unix)
|
||||
expiredTime → 凭证过期时间(Unix)
|
||||
bucket: COS Bucket 名称(如 chatbot-1234567890)
|
||||
region: COS 地域(如 ap-guangzhou)
|
||||
|
||||
Returns:
|
||||
上传结果 dict,包含:
|
||||
url (str) — COS 公网访问 URL
|
||||
uuid (str) — 文件内容 MD5
|
||||
size (int) — 文件大小(字节)
|
||||
width (int, optional) — 图片宽度(仅图片)
|
||||
height (int, optional) — 图片高度(仅图片)
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: COS 返回非 2xx 状态
|
||||
RuntimeError: credentials 字段缺失
|
||||
"""
|
||||
secret_id: str = credentials.get("encryptTmpSecretId", "")
|
||||
secret_key: str = credentials.get("encryptTmpSecretKey", "")
|
||||
session_token: str = credentials.get("encryptToken", "")
|
||||
cos_key: str = credentials.get("location", "")
|
||||
resource_url: str = credentials.get("resourceUrl", "")
|
||||
start_time: Optional[int] = credentials.get("startTime")
|
||||
expired_time: Optional[int] = credentials.get("expiredTime")
|
||||
|
||||
if not secret_id or not secret_key or not cos_key:
|
||||
raise RuntimeError(
|
||||
f"COS credentials 不完整: secretId={bool(secret_id)}, "
|
||||
f"secretKey={bool(secret_key)}, location={bool(cos_key)}"
|
||||
)
|
||||
|
||||
# 构建 COS 上传 URL(优先使用全球加速域名)
|
||||
if COS_USE_ACCELERATE:
|
||||
cos_host = f"{bucket}.cos.accelerate.myqcloud.com"
|
||||
else:
|
||||
cos_host = f"{bucket}.cos.{region}.myqcloud.com"
|
||||
|
||||
# URL encode cos_key(保留 /)
|
||||
encoded_key = urllib.parse.quote(cos_key, safe="/")
|
||||
cos_url = f"https://{cos_host}/{encoded_key.lstrip('/')}"
|
||||
|
||||
# 确定 Content-Type
|
||||
if not content_type or content_type == "application/octet-stream":
|
||||
if is_image(filename):
|
||||
content_type = guess_mime_type(filename)
|
||||
else:
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
# 计算文件 MD5 + size
|
||||
file_uuid = md5_hex(file_bytes)
|
||||
file_size = len(file_bytes)
|
||||
|
||||
# 参与签名的请求头
|
||||
sign_headers = {
|
||||
"host": cos_host,
|
||||
"content-type": content_type,
|
||||
"x-cos-security-token": session_token,
|
||||
}
|
||||
|
||||
# 计算签名有效期
|
||||
now = int(time.time())
|
||||
sign_start = start_time if start_time else now
|
||||
sign_expire = (expired_time - now) if expired_time and expired_time > now else 3600
|
||||
|
||||
authorization = _cos_sign(
|
||||
method="put",
|
||||
path=f"/{encoded_key.lstrip('/')}",
|
||||
params={},
|
||||
headers=sign_headers,
|
||||
secret_id=secret_id,
|
||||
secret_key=secret_key,
|
||||
start_time=sign_start,
|
||||
expire_seconds=sign_expire,
|
||||
)
|
||||
|
||||
put_headers = {
|
||||
"Authorization": authorization,
|
||||
"Content-Type": content_type,
|
||||
"x-cos-security-token": session_token,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"COS PUT: bucket=%s region=%s key=%s size=%d mime=%s",
|
||||
bucket, region, cos_key, file_size, content_type,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.put(
|
||||
cos_url,
|
||||
content=file_bytes,
|
||||
headers=put_headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
# 解析图片尺寸(仅图片类型)
|
||||
result: dict[str, Any] = {
|
||||
"url": resource_url or cos_url,
|
||||
"uuid": file_uuid,
|
||||
"size": file_size,
|
||||
}
|
||||
|
||||
if content_type.startswith("image/"):
|
||||
size_info = parse_image_size(file_bytes)
|
||||
if size_info:
|
||||
result["width"] = size_info["width"]
|
||||
result["height"] = size_info["height"]
|
||||
|
||||
logger.info(
|
||||
"COS 上传成功: url=%s size=%d",
|
||||
result["url"], file_size,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ============ TIM 媒体消息构建 ============
|
||||
|
||||
def build_image_msg_body(
|
||||
url: str,
|
||||
uuid: Optional[str] = None,
|
||||
filename: Optional[str] = None,
|
||||
size: int = 0,
|
||||
width: int = 0,
|
||||
height: int = 0,
|
||||
mime_type: str = "",
|
||||
) -> list[dict]:
|
||||
"""
|
||||
构建腾讯 IM TIMImageElem 消息体。
|
||||
参考:https://cloud.tencent.com/document/product/269/2720
|
||||
|
||||
Args:
|
||||
url: 图片公网访问 URL(COS resourceUrl)
|
||||
uuid: 文件 UUID(MD5 或其他唯一标识)
|
||||
filename: 文件名(uuid 为空时作为备用)
|
||||
size: 文件大小(字节)
|
||||
width: 图片宽度(像素)
|
||||
height: 图片高度(像素)
|
||||
mime_type: MIME 类型(用于确定 image_format)
|
||||
|
||||
Returns:
|
||||
TIMImageElem 消息体列表(适合直接放入 msg_body)
|
||||
"""
|
||||
_uuid = uuid or filename or _basename_from_url(url) or "image"
|
||||
image_format = get_image_format(mime_type) if mime_type else 255
|
||||
|
||||
return [
|
||||
{
|
||||
"msg_type": "TIMImageElem",
|
||||
"msg_content": {
|
||||
"uuid": _uuid,
|
||||
"image_format": image_format,
|
||||
"image_info_array": [
|
||||
{
|
||||
"type": 1, # 1 = 原图
|
||||
"size": size,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"url": url,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def build_file_msg_body(
|
||||
url: str,
|
||||
filename: str,
|
||||
uuid: Optional[str] = None,
|
||||
size: int = 0,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
构建腾讯 IM TIMFileElem 消息体。
|
||||
参考:https://cloud.tencent.com/document/product/269/2720
|
||||
|
||||
Args:
|
||||
url: 文件公网访问 URL(COS resourceUrl)
|
||||
filename: 文件名(含扩展名)
|
||||
uuid: 文件 UUID(MD5 或其他唯一标识,不传则使用 filename)
|
||||
size: 文件大小(字节)
|
||||
|
||||
Returns:
|
||||
TIMFileElem 消息体列表(适合直接放入 msg_body)
|
||||
"""
|
||||
_uuid = uuid or filename
|
||||
|
||||
return [
|
||||
{
|
||||
"msg_type": "TIMFileElem",
|
||||
"msg_content": {
|
||||
"uuid": _uuid,
|
||||
"file_name": filename,
|
||||
"file_size": size,
|
||||
"url": url,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# ============ 内部工具 ============
|
||||
|
||||
def _basename_from_url(url: str) -> str:
|
||||
"""从 URL 提取文件名。"""
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
return os.path.basename(parsed.path)
|
||||
except Exception:
|
||||
return ""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,558 @@
|
||||
"""
|
||||
Yuanbao sticker (TIMFaceElem) support.
|
||||
|
||||
Ported from yuanbao-openclaw-plugin/src/sticker/.
|
||||
|
||||
TIMFaceElem wire format:
|
||||
{
|
||||
"msg_type": "TIMFaceElem",
|
||||
"msg_content": {
|
||||
"index": 0, # always 0 per Yuanbao convention
|
||||
"data": "<json>", # serialised sticker metadata
|
||||
}
|
||||
}
|
||||
|
||||
The `data` field carries a JSON string with the sticker's metadata so the
|
||||
receiver can look up the correct asset in the emoji pack.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Optional
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sticker catalogue – ported from builtin-stickers.json
|
||||
# Key : canonical name (Chinese)
|
||||
# Value : {sticker_id, package_id, name, description, width, height, formats}
|
||||
# ---------------------------------------------------------------------------
|
||||
STICKER_MAP: dict[str, dict] = {
|
||||
"六六六": {
|
||||
"sticker_id": "278", "package_id": "1003", "name": "六六六",
|
||||
"description": "666 厉害 牛 棒 绝了 好强 awesome",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"我想开了": {
|
||||
"sticker_id": "262", "package_id": "1003", "name": "我想开了",
|
||||
"description": "想开 佛系 释怀 顿悟 看淡了 无所谓",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"害羞": {
|
||||
"sticker_id": "130", "package_id": "1003", "name": "害羞",
|
||||
"description": "腼腆 不好意思 脸红 娇羞 羞涩 捂脸",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"比心": {
|
||||
"sticker_id": "252", "package_id": "1003", "name": "比心",
|
||||
"description": "笔芯 爱你 爱心手势 love heart 喜欢你",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"委屈": {
|
||||
"sticker_id": "125", "package_id": "1003", "name": "委屈",
|
||||
"description": "难过 想哭 可怜巴巴 瘪嘴 受伤 被欺负",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"亲亲": {
|
||||
"sticker_id": "146", "package_id": "1003", "name": "亲亲",
|
||||
"description": "么么 mua 亲一下 kiss 飞吻 啵",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"酷": {
|
||||
"sticker_id": "131", "package_id": "1003", "name": "酷",
|
||||
"description": "帅 墨镜 cool 高冷 有型 swagger",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"睡": {
|
||||
"sticker_id": "145", "package_id": "1003", "name": "睡",
|
||||
"description": "睡觉 困 zzZ 打盹 躺平 休眠 sleepy",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"发呆": {
|
||||
"sticker_id": "152", "package_id": "1003", "name": "发呆",
|
||||
"description": "懵 愣住 放空 呆滞 出神 脑子空白",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"可怜": {
|
||||
"sticker_id": "157", "package_id": "1003", "name": "可怜",
|
||||
"description": "卖萌 求饶 委屈巴巴 弱小 拜托 眼巴巴",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"摊手": {
|
||||
"sticker_id": "200", "package_id": "1003", "name": "摊手",
|
||||
"description": "无奈 没办法 耸肩 随便 那咋整 whatever",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"头大": {
|
||||
"sticker_id": "213", "package_id": "1003", "name": "头大",
|
||||
"description": "头疼 烦恼 郁闷 难搞 崩溃 一团乱",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"吓": {
|
||||
"sticker_id": "256", "package_id": "1003", "name": "吓",
|
||||
"description": "害怕 惊恐 震惊 吓一跳 恐怖 怂",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"吐血": {
|
||||
"sticker_id": "203", "package_id": "1003", "name": "吐血",
|
||||
"description": "无语 崩溃 被雷 内伤 一口老血 屮",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"哼": {
|
||||
"sticker_id": "185", "package_id": "1003", "name": "哼",
|
||||
"description": "傲娇 生气 不满 撇嘴 不理 赌气",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"嘿嘿": {
|
||||
"sticker_id": "220", "package_id": "1003", "name": "嘿嘿",
|
||||
"description": "坏笑 猥琐笑 偷笑 憨笑 得意 你懂的",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"头秃": {
|
||||
"sticker_id": "218", "package_id": "1003", "name": "头秃",
|
||||
"description": "程序员 加班 焦虑 没头发 秃了 肝爆",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"暗中观察": {
|
||||
"sticker_id": "221", "package_id": "1003", "name": "暗中观察",
|
||||
"description": "窥屏 潜水 偷偷看 角落 围观 屏住呼吸",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"我酸了": {
|
||||
"sticker_id": "224", "package_id": "1003", "name": "我酸了",
|
||||
"description": "嫉妒 柠檬精 羡慕 吃柠檬 眼红 恰柠檬",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"打call": {
|
||||
"sticker_id": "246", "package_id": "1003", "name": "打call",
|
||||
"description": "应援 加油 支持 喝彩 助威 call",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"庆祝": {
|
||||
"sticker_id": "251", "package_id": "1003", "name": "庆祝",
|
||||
"description": "祝贺 开心 耶 party 胜利 干杯",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"奋斗": {
|
||||
"sticker_id": "151", "package_id": "1003", "name": "奋斗",
|
||||
"description": "努力 加油 拼搏 冲 干劲 卷起来",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"惊讶": {
|
||||
"sticker_id": "143", "package_id": "1003", "name": "惊讶",
|
||||
"description": "震惊 哇 不敢相信 OMG 居然 这么离谱",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"疑问": {
|
||||
"sticker_id": "144", "package_id": "1003", "name": "疑问",
|
||||
"description": "问号 不懂 啥 为什么 啥情况 懵逼问",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"仔细分析": {
|
||||
"sticker_id": "248", "package_id": "1003", "name": "仔细分析",
|
||||
"description": "思考 推敲 认真 研究 琢磨 让我想想",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"撅嘴": {
|
||||
"sticker_id": "184", "package_id": "1003", "name": "撅嘴",
|
||||
"description": "嘟嘴 卖萌 不高兴 撒娇 嘴翘",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"泪奔": {
|
||||
"sticker_id": "199", "package_id": "1003", "name": "泪奔",
|
||||
"description": "大哭 伤心 破防 感动哭 泪流满面 呜呜",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"尊嘟假嘟": {
|
||||
"sticker_id": "276", "package_id": "1003", "name": "尊嘟假嘟",
|
||||
"description": "真的假的 真假 可爱问 你骗我 是不是",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"略略略": {
|
||||
"sticker_id": "113", "package_id": "1003", "name": "略略略",
|
||||
"description": "调皮 吐舌 不服 略 气死你 鬼脸",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"困": {
|
||||
"sticker_id": "180", "package_id": "1003", "name": "困",
|
||||
"description": "想睡 倦 打哈欠 睁不开眼 好困啊 sleepy",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"折磨": {
|
||||
"sticker_id": "181", "package_id": "1003", "name": "折磨",
|
||||
"description": "难受 痛苦 煎熬 蚌埠住了 受不了 要命",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"抠鼻": {
|
||||
"sticker_id": "182", "package_id": "1003", "name": "抠鼻",
|
||||
"description": "不屑 无聊 淡定 无所谓 鄙视 挖鼻",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"鼓掌": {
|
||||
"sticker_id": "183", "package_id": "1003", "name": "鼓掌",
|
||||
"description": "拍手 叫好 赞同 666 喝彩 掌声",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"斜眼笑": {
|
||||
"sticker_id": "204", "package_id": "1003", "name": "斜眼笑",
|
||||
"description": "滑稽 坏笑 doge 意味深长 阴阳怪气 嘿嘿嘿",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"辣眼睛": {
|
||||
"sticker_id": "216", "package_id": "1003", "name": "辣眼睛",
|
||||
"description": "看不下去 cringe 毁三观 太丑了 瞎了",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"哦哟": {
|
||||
"sticker_id": "217", "package_id": "1003", "name": "哦哟",
|
||||
"description": "惊讶 起哄 哇哦 有戏 不简单 哟",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"吃瓜": {
|
||||
"sticker_id": "222", "package_id": "1003", "name": "吃瓜",
|
||||
"description": "围观 看戏 八卦 路人 看热闹 板凳",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"狗头": {
|
||||
"sticker_id": "225", "package_id": "1003", "name": "狗头",
|
||||
"description": "doge 保命 开玩笑 滑稽 反讽 懂的都懂",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"敬礼": {
|
||||
"sticker_id": "227", "package_id": "1003", "name": "敬礼",
|
||||
"description": "salute 尊重 收到 遵命 致敬 报告",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"哦": {
|
||||
"sticker_id": "231", "package_id": "1003", "name": "哦",
|
||||
"description": "知道了 明白 敷衍 嗯 这样啊 收到",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"拿到红包": {
|
||||
"sticker_id": "236", "package_id": "1003", "name": "拿到红包",
|
||||
"description": "红包 谢谢老板 发财 开心 抢到了 欧气",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"牛吖": {
|
||||
"sticker_id": "239", "package_id": "1003", "name": "牛吖",
|
||||
"description": "牛 厉害 强 666 佩服 大佬",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"贴贴": {
|
||||
"sticker_id": "272", "package_id": "1003", "name": "贴贴",
|
||||
"description": "抱抱 亲昵 蹭蹭 亲密 靠靠 撒娇贴",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"爱心": {
|
||||
"sticker_id": "138", "package_id": "1003", "name": "爱心",
|
||||
"description": "心 love 喜欢你 红心 示爱 么么哒",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"晚安": {
|
||||
"sticker_id": "170", "package_id": "1003", "name": "晚安",
|
||||
"description": "好梦 睡了 night 早点休息 安啦 moon",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"太阳": {
|
||||
"sticker_id": "176", "package_id": "1003", "name": "太阳",
|
||||
"description": "晴天 早上好 阳光 morning 好天气 日",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"柠檬": {
|
||||
"sticker_id": "266", "package_id": "1003", "name": "柠檬",
|
||||
"description": "酸 嫉妒 柠檬精 羡慕 我酸 恰柠檬",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"大冤种": {
|
||||
"sticker_id": "267", "package_id": "1003", "name": "大冤种",
|
||||
"description": "倒霉 吃亏 自嘲 好心没好报 背锅 工具人",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"吐了": {
|
||||
"sticker_id": "132", "package_id": "1003", "name": "吐了",
|
||||
"description": "恶心 yue 受不了 嫌弃 想吐 生理不适",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"怒": {
|
||||
"sticker_id": "134", "package_id": "1003", "name": "怒",
|
||||
"description": "生气 愤怒 火大 暴躁 气炸 怼",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"玫瑰": {
|
||||
"sticker_id": "165", "package_id": "1003", "name": "玫瑰",
|
||||
"description": "花 示爱 表白 浪漫 送你花 情人节",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"凋谢": {
|
||||
"sticker_id": "119", "package_id": "1003", "name": "凋谢",
|
||||
"description": "花谢 失恋 难过 枯萎 心碎 凉了",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"点赞": {
|
||||
"sticker_id": "159", "package_id": "1003", "name": "点赞",
|
||||
"description": "赞 认同 好棒 good like 大拇指 顶",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"握手": {
|
||||
"sticker_id": "164", "package_id": "1003", "name": "握手",
|
||||
"description": "合作 你好 商务 hello deal 成交 友好",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"抱拳": {
|
||||
"sticker_id": "163", "package_id": "1003", "name": "抱拳",
|
||||
"description": "谢谢 失敬 江湖 承让 拜托 有礼",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"ok": {
|
||||
"sticker_id": "169", "package_id": "1003", "name": "ok",
|
||||
"description": "好的 收到 没问题 okay 行 可以 懂了",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"拳头": {
|
||||
"sticker_id": "174", "package_id": "1003", "name": "拳头",
|
||||
"description": "加油 干 冲 fight 力量 击拳 硬气",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"鞭炮": {
|
||||
"sticker_id": "191", "package_id": "1003", "name": "鞭炮",
|
||||
"description": "过年 喜庆 爆竹 春节 噼里啪啦 红",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"烟花": {
|
||||
"sticker_id": "258", "package_id": "1003", "name": "烟花",
|
||||
"description": "庆典 漂亮 新年 嘭 绽放 节日快乐",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_sticker_by_name(name: str) -> Optional[dict]:
|
||||
"""
|
||||
按名称查找贴纸,支持模糊匹配。
|
||||
|
||||
匹配优先级:
|
||||
1. 完全相等(name)
|
||||
2. name 包含查询词(前缀/子串)
|
||||
3. description 包含查询词(同义词搜索)
|
||||
4. 通用模糊评分(与 sticker-search 同算法),命中即返回得分最高的一条
|
||||
|
||||
返回 sticker dict,找不到返回 None。
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
|
||||
query = name.strip()
|
||||
|
||||
if query in STICKER_MAP:
|
||||
return STICKER_MAP[query]
|
||||
|
||||
for key, sticker in STICKER_MAP.items():
|
||||
if query in key or key in query:
|
||||
return sticker
|
||||
|
||||
for sticker in STICKER_MAP.values():
|
||||
desc = sticker.get("description", "")
|
||||
if query in desc:
|
||||
return sticker
|
||||
|
||||
matches = search_stickers(query, limit=1)
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def get_random_sticker(category: str = None) -> dict:
|
||||
"""
|
||||
随机返回一个贴纸。
|
||||
|
||||
若指定 category,则在 description 中含有该关键词的贴纸里随机选取;
|
||||
category 为 None 时从全表随机。
|
||||
"""
|
||||
if category:
|
||||
candidates = [
|
||||
s for s in STICKER_MAP.values()
|
||||
if category in s.get("description", "") or category in s.get("name", "")
|
||||
]
|
||||
if candidates:
|
||||
return random.choice(candidates)
|
||||
return random.choice(list(STICKER_MAP.values()))
|
||||
|
||||
|
||||
def get_sticker_by_id(sticker_id: str) -> Optional[dict]:
|
||||
"""按 sticker_id 精确查找贴纸。"""
|
||||
if not sticker_id:
|
||||
return None
|
||||
sid = str(sticker_id).strip()
|
||||
for sticker in STICKER_MAP.values():
|
||||
if sticker.get("sticker_id") == sid:
|
||||
return sticker
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 模糊搜索(对齐 chatbot-web yuanbao-openclaw-plugin/sticker-cache.ts.searchStickers)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PUNCT_RE = re.compile(r"[\s\u3000\-_·.,,。!!??\"“”'‘’、/\\]+")
|
||||
|
||||
|
||||
def _normalize_text(raw: str) -> str:
|
||||
return unicodedata.normalize("NFKC", str(raw or "")).strip().lower()
|
||||
|
||||
|
||||
def _compact_text(raw: str) -> str:
|
||||
return _PUNCT_RE.sub("", _normalize_text(raw))
|
||||
|
||||
|
||||
def _multiset_char_hit_ratio(needle: str, haystack: str) -> float:
|
||||
if not needle:
|
||||
return 0.0
|
||||
bag: dict[str, int] = {}
|
||||
for ch in haystack:
|
||||
bag[ch] = bag.get(ch, 0) + 1
|
||||
hits = 0
|
||||
for ch in needle:
|
||||
n = bag.get(ch, 0)
|
||||
if n > 0:
|
||||
hits += 1
|
||||
bag[ch] = n - 1
|
||||
return hits / len(needle)
|
||||
|
||||
|
||||
def _bigram_jaccard(a: str, b: str) -> float:
|
||||
if len(a) < 2 or len(b) < 2:
|
||||
return 0.0
|
||||
A = {a[i:i + 2] for i in range(len(a) - 1)}
|
||||
B = {b[i:i + 2] for i in range(len(b) - 1)}
|
||||
inter = len(A & B)
|
||||
union = len(A) + len(B) - inter
|
||||
return inter / union if union else 0.0
|
||||
|
||||
|
||||
def _longest_subsequence_ratio(needle: str, haystack: str) -> float:
|
||||
if not needle:
|
||||
return 0.0
|
||||
j = 0
|
||||
for ch in haystack:
|
||||
if j >= len(needle):
|
||||
break
|
||||
if ch == needle[j]:
|
||||
j += 1
|
||||
return j / len(needle)
|
||||
|
||||
|
||||
def _score_field(haystack: str, query: str) -> float:
|
||||
hay = _normalize_text(haystack)
|
||||
q = _normalize_text(query)
|
||||
if not hay or not q:
|
||||
return 0.0
|
||||
hay_c = _compact_text(haystack)
|
||||
q_c = _compact_text(query)
|
||||
best = 0.0
|
||||
if hay == q:
|
||||
best = max(best, 100.0)
|
||||
if q in hay:
|
||||
best = max(best, 92 + min(6, len(q)))
|
||||
if len(q) >= 2 and hay.startswith(q):
|
||||
best = max(best, 88.0)
|
||||
if q_c and q_c in hay_c:
|
||||
best = max(best, 86.0)
|
||||
best = max(best, _multiset_char_hit_ratio(q_c, hay_c) * 62)
|
||||
best = max(best, _bigram_jaccard(q_c, hay_c) * 58)
|
||||
best = max(best, _longest_subsequence_ratio(q_c, hay_c) * 52)
|
||||
if len(q) == 1 and q in hay:
|
||||
best = max(best, 68.0)
|
||||
return best
|
||||
|
||||
|
||||
def search_stickers(query: str, limit: int = 10) -> list[dict]:
|
||||
"""
|
||||
在内置贴纸表中按模糊匹配排序返回前 N 条结果。
|
||||
|
||||
评分综合 name/description 字段的子串、字符多重集覆盖、bigram Jaccard、子序列比例。
|
||||
name 权重略高于 description(×0.88)。空 query 时按字典顺序返回前 N 条。
|
||||
"""
|
||||
safe_limit = max(1, min(500, int(limit) if limit else 10))
|
||||
if not query or not _normalize_text(query):
|
||||
return list(STICKER_MAP.values())[:safe_limit]
|
||||
|
||||
scored: list[tuple[float, dict]] = []
|
||||
for sticker in STICKER_MAP.values():
|
||||
name_s = _score_field(sticker.get("name", ""), query)
|
||||
desc_s = _score_field(sticker.get("description", ""), query) * 0.88
|
||||
sid = str(sticker.get("sticker_id", "")).strip()
|
||||
q_norm = _normalize_text(query)
|
||||
id_s = 0.0
|
||||
if sid and q_norm:
|
||||
sid_norm = _normalize_text(sid)
|
||||
if sid_norm == q_norm:
|
||||
id_s = 100.0
|
||||
elif q_norm in sid_norm:
|
||||
id_s = 84.0
|
||||
scored.append((max(name_s, desc_s, id_s), sticker))
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
top = scored[0][0] if scored else 0
|
||||
if top <= 0:
|
||||
return [s for _, s in scored[:safe_limit]]
|
||||
|
||||
if top >= 22:
|
||||
floor = 18.0
|
||||
elif top >= 12:
|
||||
floor = max(10.0, top * 0.5)
|
||||
else:
|
||||
floor = max(6.0, top * 0.35)
|
||||
|
||||
filtered = [pair for pair in scored if pair[0] >= floor]
|
||||
out = filtered if filtered else scored
|
||||
return [s for _, s in out[:safe_limit]]
|
||||
|
||||
|
||||
def build_face_msg_body(
|
||||
face_index: int,
|
||||
face_type: int = 1,
|
||||
data: Optional[str] = None,
|
||||
) -> list:
|
||||
"""
|
||||
构造 TIMFaceElem 消息体。
|
||||
|
||||
Yuanbao 约定:
|
||||
- index 固定传 0(服务端通过 data 字段识别具体表情)
|
||||
- data 为 JSON 字符串,包含 sticker_id / package_id 等字段
|
||||
|
||||
Args:
|
||||
face_index: 保留字段,暂时不影响 wire format(Yuanbao 固定 index=0)。
|
||||
当 face_index > 0 时视为旧版 QQ 表情 ID,直接放入 index。
|
||||
face_type: 保留字段(兼容旧接口,当前未使用)。
|
||||
data: 已序列化的 JSON 字符串;为 None 时仅传 index。
|
||||
|
||||
Returns:
|
||||
符合 Yuanbao TIM 协议的 msg_body list,如::
|
||||
|
||||
[{"msg_type": "TIMFaceElem", "msg_content": {"index": 0, "data": "..."}}]
|
||||
"""
|
||||
msg_content: dict = {"index": face_index}
|
||||
if data is not None:
|
||||
msg_content["data"] = data
|
||||
return [{"msg_type": "TIMFaceElem", "msg_content": msg_content}]
|
||||
|
||||
|
||||
def build_sticker_msg_body(sticker: dict) -> list:
|
||||
"""
|
||||
从 STICKER_MAP 中的 sticker dict 直接构造 TIMFaceElem 消息体。
|
||||
|
||||
这是 send_sticker() 的内部辅助,确保 data 字段与原始 JS 插件一致。
|
||||
"""
|
||||
data_payload = json.dumps(
|
||||
{
|
||||
"sticker_id": sticker["sticker_id"],
|
||||
"package_id": sticker["package_id"],
|
||||
"width": sticker.get("width", 128),
|
||||
"height": sticker.get("height", 128),
|
||||
"formats": sticker.get("formats", "png"),
|
||||
"name": sticker["name"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return build_face_msg_body(face_index=0, data=data_payload)
|
||||
+360
-34
@@ -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] = {}
|
||||
|
||||
@@ -753,10 +763,27 @@ class GatewayRunner:
|
||||
retention_days=int(_sess_cfg.get("retention_days", 90)),
|
||||
min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)),
|
||||
vacuum=bool(_sess_cfg.get("vacuum_after_prune", True)),
|
||||
sessions_dir=self.config.sessions_dir,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("state.db auto-maintenance skipped: %s", exc)
|
||||
|
||||
# Opportunistic shadow-repo cleanup — deletes orphan/stale
|
||||
# checkpoint repos under ~/.hermes/checkpoints/. Opt-in via
|
||||
# checkpoints.auto_prune, idempotent via .last_prune marker.
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_full_config
|
||||
_ckpt_cfg = (_load_full_config().get("checkpoints") or {})
|
||||
if _ckpt_cfg.get("auto_prune", False):
|
||||
from tools.checkpoint_manager import maybe_auto_prune_checkpoints
|
||||
maybe_auto_prune_checkpoints(
|
||||
retention_days=int(_ckpt_cfg.get("retention_days", 7)),
|
||||
min_interval_hours=int(_ckpt_cfg.get("min_interval_hours", 24)),
|
||||
delete_orphans=bool(_ckpt_cfg.get("delete_orphans", True)),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("checkpoint auto-maintenance skipped: %s", exc)
|
||||
|
||||
# DM pairing store for code-based user authorization
|
||||
from gateway.pairing import PairingStore
|
||||
self.pairing_store = PairingStore()
|
||||
@@ -1202,7 +1229,80 @@ class GatewayRunner:
|
||||
return "restarting" if self._restart_requested else "shutting down"
|
||||
|
||||
def _queue_during_drain_enabled(self) -> bool:
|
||||
return self._restart_requested and self._busy_input_mode == "queue"
|
||||
# Both "queue" and "steer" modes imply the user doesn't want messages
|
||||
# to be lost during restart — queue them for the newly-spawned gateway
|
||||
# process to pick up. "interrupt" mode drops them (current behaviour).
|
||||
return self._restart_requested and self._busy_input_mode in ("queue", "steer")
|
||||
|
||||
# -------- /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:
|
||||
@@ -1433,7 +1533,11 @@ class GatewayRunner:
|
||||
mode = str(cfg.get("display", {}).get("busy_input_mode", "") or "").strip().lower()
|
||||
except Exception:
|
||||
pass
|
||||
return "queue" if mode == "queue" else "interrupt"
|
||||
if mode == "queue":
|
||||
return "queue"
|
||||
if mode == "steer":
|
||||
return "steer"
|
||||
return "interrupt"
|
||||
|
||||
@staticmethod
|
||||
def _load_restart_drain_timeout() -> float:
|
||||
@@ -1571,18 +1675,46 @@ class GatewayRunner:
|
||||
if not adapter:
|
||||
return False # let default path handle it
|
||||
|
||||
running_agent = self._running_agents.get(session_key)
|
||||
|
||||
# Steer mode: inject mid-run via running_agent.steer() instead of
|
||||
# queueing + interrupting. If the agent isn't running yet
|
||||
# (sentinel) or lacks steer(), or the payload is empty, fall back
|
||||
# to queue semantics so nothing is lost.
|
||||
effective_mode = self._busy_input_mode
|
||||
steered = False
|
||||
if effective_mode == "steer":
|
||||
steer_text = (event.text or "").strip()
|
||||
can_steer = (
|
||||
steer_text
|
||||
and running_agent is not None
|
||||
and running_agent is not _AGENT_PENDING_SENTINEL
|
||||
and hasattr(running_agent, "steer")
|
||||
)
|
||||
if can_steer:
|
||||
try:
|
||||
steered = bool(running_agent.steer(steer_text))
|
||||
except Exception as exc:
|
||||
logger.warning("Gateway steer failed for session %s: %s", session_key, exc)
|
||||
steered = False
|
||||
if not steered:
|
||||
# Fall back to queue (merge into pending messages, no interrupt)
|
||||
effective_mode = "queue"
|
||||
|
||||
# Store the message so it's processed as the next turn after the
|
||||
# current run finishes (or is interrupted).
|
||||
from gateway.platforms.base import merge_pending_message_event
|
||||
merge_pending_message_event(adapter._pending_messages, session_key, event)
|
||||
# current run finishes (or is interrupted). Skip this for a
|
||||
# successful steer — the text already landed inside the run and
|
||||
# must NOT also be replayed as a next-turn user message.
|
||||
if not steered:
|
||||
merge_pending_message_event(adapter._pending_messages, session_key, event)
|
||||
|
||||
is_queue_mode = self._busy_input_mode == "queue"
|
||||
is_queue_mode = effective_mode == "queue"
|
||||
is_steer_mode = effective_mode == "steer"
|
||||
|
||||
# If not in queue mode, interrupt the running agent immediately.
|
||||
# If not in queue/steer mode, interrupt the running agent immediately.
|
||||
# This aborts in-flight tool calls and causes the agent loop to exit
|
||||
# at the next check point.
|
||||
running_agent = self._running_agents.get(session_key)
|
||||
if not is_queue_mode and running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
|
||||
if effective_mode == "interrupt" and running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
|
||||
try:
|
||||
running_agent.interrupt(event.text)
|
||||
except Exception:
|
||||
@@ -1619,7 +1751,12 @@ class GatewayRunner:
|
||||
pass
|
||||
|
||||
status_detail = f" ({', '.join(status_parts)})" if status_parts else ""
|
||||
if is_queue_mode:
|
||||
if is_steer_mode:
|
||||
message = (
|
||||
f"⏩ Steered into current run{status_detail}. "
|
||||
f"Your message arrives after the next tool call."
|
||||
)
|
||||
elif is_queue_mode:
|
||||
message = (
|
||||
f"⏳ Queued for the next turn{status_detail}. "
|
||||
f"I'll respond once the current task finishes."
|
||||
@@ -1643,9 +1780,15 @@ class GatewayRunner:
|
||||
)
|
||||
_user_cfg = _load_gateway_config()
|
||||
if not is_seen(_user_cfg, BUSY_INPUT_FLAG):
|
||||
if is_steer_mode:
|
||||
_hint_mode = "steer"
|
||||
elif is_queue_mode:
|
||||
_hint_mode = "queue"
|
||||
else:
|
||||
_hint_mode = "interrupt"
|
||||
message = (
|
||||
f"{message}\n\n"
|
||||
f"{busy_input_hint_gateway('queue' if is_queue_mode else 'interrupt')}"
|
||||
f"{busy_input_hint_gateway(_hint_mode)}"
|
||||
)
|
||||
mark_seen(_hermes_home / "config.yaml", BUSY_INPUT_FLAG)
|
||||
except Exception as _onb_err:
|
||||
@@ -1996,6 +2139,7 @@ class GatewayRunner:
|
||||
"WEIXIN_ALLOWED_USERS",
|
||||
"BLUEBUBBLES_ALLOWED_USERS",
|
||||
"QQ_ALLOWED_USERS",
|
||||
"YUANBAO_ALLOWED_USERS",
|
||||
"GATEWAY_ALLOWED_USERS")
|
||||
)
|
||||
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any(
|
||||
@@ -2010,7 +2154,8 @@ class GatewayRunner:
|
||||
"WECOM_CALLBACK_ALLOW_ALL_USERS",
|
||||
"WEIXIN_ALLOW_ALL_USERS",
|
||||
"BLUEBUBBLES_ALLOW_ALL_USERS",
|
||||
"QQ_ALLOW_ALL_USERS")
|
||||
"QQ_ALLOW_ALL_USERS",
|
||||
"YUANBAO_ALLOW_ALL_USERS")
|
||||
)
|
||||
if not _any_allowlist and not _allow_all:
|
||||
logger.warning(
|
||||
@@ -2254,7 +2399,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 +2683,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:
|
||||
@@ -2720,6 +2865,23 @@ class GatewayRunner:
|
||||
|
||||
self._finalize_shutdown_agents(active_agents)
|
||||
|
||||
# Also shut down memory providers on idle cached agents.
|
||||
# _finalize_shutdown_agents only handles agents that were
|
||||
# mid-turn at drain time; the _agent_cache may still hold
|
||||
# idle agents whose MemoryProviders never received
|
||||
# on_session_end().
|
||||
_cache_lock = getattr(self, "_agent_cache_lock", None)
|
||||
_cache = getattr(self, "_agent_cache", None)
|
||||
if _cache_lock is not None and _cache is not None:
|
||||
with _cache_lock:
|
||||
_idle_agents = list(_cache.values())
|
||||
_cache.clear()
|
||||
for _entry in _idle_agents:
|
||||
_agent = (
|
||||
_entry[0] if isinstance(_entry, tuple) else _entry
|
||||
)
|
||||
self._cleanup_agent_resources(_agent)
|
||||
|
||||
for platform, adapter in list(self.adapters.items()):
|
||||
try:
|
||||
await adapter.cancel_background_tasks()
|
||||
@@ -2970,8 +3132,14 @@ class GatewayRunner:
|
||||
return None
|
||||
return QQAdapter(config)
|
||||
|
||||
return None
|
||||
elif platform == Platform.YUANBAO:
|
||||
from gateway.platforms.yuanbao import YuanbaoAdapter, WEBSOCKETS_AVAILABLE
|
||||
if not WEBSOCKETS_AVAILABLE:
|
||||
logger.warning("Yuanbao: websockets not installed. Run: pip install websockets")
|
||||
return None
|
||||
return YuanbaoAdapter(config)
|
||||
|
||||
return None
|
||||
def _is_user_authorized(self, source: SessionSource) -> bool:
|
||||
"""
|
||||
Check if a user is authorized to use the bot.
|
||||
@@ -3012,6 +3180,7 @@ class GatewayRunner:
|
||||
Platform.WEIXIN: "WEIXIN_ALLOWED_USERS",
|
||||
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS",
|
||||
Platform.QQBOT: "QQ_ALLOWED_USERS",
|
||||
Platform.YUANBAO: "YUANBAO_ALLOWED_USERS",
|
||||
}
|
||||
platform_group_env_map = {
|
||||
Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_USERS",
|
||||
@@ -3034,6 +3203,7 @@ class GatewayRunner:
|
||||
Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS",
|
||||
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS",
|
||||
Platform.QQBOT: "QQ_ALLOW_ALL_USERS",
|
||||
Platform.YUANBAO: "YUANBAO_ALLOW_ALL_USERS",
|
||||
}
|
||||
|
||||
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
|
||||
@@ -3282,6 +3452,10 @@ class GatewayRunner:
|
||||
# The update process (detached) wrote .update_prompt.json; the watcher
|
||||
# forwarded it to the user; now the user's reply goes back via
|
||||
# .update_response so the update process can continue.
|
||||
#
|
||||
# IMPORTANT: recognized slash commands must bypass this interception.
|
||||
# Otherwise control/session commands like /new or /help get silently
|
||||
# consumed as update answers instead of being dispatched normally.
|
||||
_quick_key = self._session_key_for_source(source)
|
||||
_update_prompts = getattr(self, "_update_prompt_pending", {})
|
||||
if _update_prompts.get(_quick_key):
|
||||
@@ -3293,7 +3467,22 @@ class GatewayRunner:
|
||||
elif cmd in ("deny", "no"):
|
||||
response_text = "n"
|
||||
else:
|
||||
response_text = raw
|
||||
_recognized_cmd = None
|
||||
if cmd:
|
||||
try:
|
||||
from hermes_cli.commands import resolve_command as _resolve_update_cmd
|
||||
except Exception:
|
||||
_resolve_update_cmd = None
|
||||
if _resolve_update_cmd is not None:
|
||||
try:
|
||||
_cmd_def = _resolve_update_cmd(cmd)
|
||||
_recognized_cmd = _cmd_def.name if _cmd_def else None
|
||||
except Exception:
|
||||
_recognized_cmd = None
|
||||
if _recognized_cmd:
|
||||
response_text = ""
|
||||
else:
|
||||
response_text = raw
|
||||
if response_text:
|
||||
response_path = _hermes_home / ".update_response"
|
||||
try:
|
||||
@@ -3306,6 +3495,30 @@ class GatewayRunner:
|
||||
_update_prompts.pop(_quick_key, None)
|
||||
label = response_text if len(response_text) <= 20 else response_text[:20] + "…"
|
||||
return f"✓ Sent `{label}` to the update process."
|
||||
# Recognized slash command during a pending update prompt:
|
||||
# unblock the detached update subprocess by writing a blank
|
||||
# response so ``_gateway_prompt`` returns the prompt's default
|
||||
# (typically a safe "n" / skip) and exits cleanly instead of
|
||||
# blocking on stdin until the 30-minute watcher timeout.
|
||||
# The slash command then falls through to normal dispatch.
|
||||
if _recognized_cmd:
|
||||
response_path = _hermes_home / ".update_response"
|
||||
try:
|
||||
tmp = response_path.with_suffix(".tmp")
|
||||
tmp.write_text("")
|
||||
tmp.replace(response_path)
|
||||
logger.info(
|
||||
"Recognized /%s during pending update prompt for %s; "
|
||||
"cancelled prompt with default and dispatching command",
|
||||
_recognized_cmd,
|
||||
_quick_key,
|
||||
)
|
||||
except OSError as e:
|
||||
logger.warning(
|
||||
"Failed to write cancel response for pending update prompt: %s",
|
||||
e,
|
||||
)
|
||||
_update_prompts.pop(_quick_key, None)
|
||||
|
||||
# PRIORITY handling when an agent is already running for this session.
|
||||
# Default behavior is to interrupt immediately so user text/stop messages
|
||||
@@ -3416,7 +3629,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 +3646,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
|
||||
@@ -3608,6 +3827,24 @@ class GatewayRunner:
|
||||
logger.debug("PRIORITY queue follow-up for session %s", _quick_key)
|
||||
self._queue_or_replace_pending_event(_quick_key, event)
|
||||
return None
|
||||
if self._busy_input_mode == "steer":
|
||||
# Steer mode: inject text into the running agent mid-run via
|
||||
# agent.steer(). Falls back to queue semantics if the payload
|
||||
# is empty, the agent lacks steer(), or steer() rejects.
|
||||
steer_text = (event.text or "").strip()
|
||||
steered = False
|
||||
if steer_text and hasattr(running_agent, "steer"):
|
||||
try:
|
||||
steered = bool(running_agent.steer(steer_text))
|
||||
except Exception as exc:
|
||||
logger.warning("PRIORITY steer failed for session %s: %s", _quick_key, exc)
|
||||
steered = False
|
||||
if steered:
|
||||
logger.debug("PRIORITY steer for session %s", _quick_key)
|
||||
return None
|
||||
logger.debug("PRIORITY steer-fallback-to-queue for session %s", _quick_key)
|
||||
self._queue_or_replace_pending_event(_quick_key, event)
|
||||
return None
|
||||
logger.debug("PRIORITY interrupt for session %s", _quick_key)
|
||||
running_agent.interrupt(event.text)
|
||||
if _quick_key in self._pending_messages:
|
||||
@@ -4118,7 +4355,14 @@ class GatewayRunner:
|
||||
session_entry = self.session_store.get_or_create_session(source)
|
||||
session_key = session_entry.session_key
|
||||
if getattr(session_entry, "was_auto_reset", False):
|
||||
# Treat auto-reset as a full conversation boundary — drop every
|
||||
# session-scoped transient state so the fresh session does not
|
||||
# inherit the previous conversation's model/reasoning overrides
|
||||
# or a queued "/model switched" note.
|
||||
self._session_model_overrides.pop(session_key, None)
|
||||
self._set_session_reasoning_override(session_key, None)
|
||||
if hasattr(self, "_pending_model_notes"):
|
||||
self._pending_model_notes.pop(session_key, None)
|
||||
|
||||
# Emit session:start for new or auto-reset sessions
|
||||
_is_new_session = (
|
||||
@@ -4232,7 +4476,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 +4764,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."
|
||||
)
|
||||
|
||||
@@ -4790,6 +5042,8 @@ class GatewayRunner:
|
||||
self._evict_cached_agent(session_key)
|
||||
self._session_model_overrides.pop(session_key, None)
|
||||
self._set_session_reasoning_override(session_key, None)
|
||||
if hasattr(self, "_pending_model_notes"):
|
||||
self._pending_model_notes.pop(session_key, None)
|
||||
response = (response or "") + (
|
||||
"\n\n🔄 Session auto-reset — the conversation exceeded the "
|
||||
"maximum context size and could not be compressed further. "
|
||||
@@ -5058,6 +5312,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()
|
||||
@@ -5077,6 +5338,8 @@ class GatewayRunner:
|
||||
# picks up configured defaults instead of previous session switches.
|
||||
self._session_model_overrides.pop(session_key, None)
|
||||
self._set_session_reasoning_override(session_key, None)
|
||||
if hasattr(self, "_pending_model_notes"):
|
||||
self._pending_model_notes.pop(session_key, None)
|
||||
|
||||
# Clear session-scoped dangerous-command approvals and /yolo state.
|
||||
# /new is a conversation-boundary operation — approval state from the
|
||||
@@ -5165,6 +5428,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 +5451,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)}",
|
||||
])
|
||||
@@ -6640,6 +6911,7 @@ class GatewayRunner:
|
||||
chat_id=source.chat_id,
|
||||
image_url=image_url,
|
||||
caption=alt_text,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -6650,6 +6922,7 @@ class GatewayRunner:
|
||||
await adapter.send_document(
|
||||
chat_id=source.chat_id,
|
||||
file_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -7473,7 +7746,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 +8685,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}]"
|
||||
@@ -8615,7 +8888,7 @@ class GatewayRunner:
|
||||
return True
|
||||
|
||||
def _clear_session_boundary_security_state(self, session_key: str) -> None:
|
||||
"""Clear approval state that must not survive a real conversation switch."""
|
||||
"""Clear per-session control state that must not survive a boundary switch."""
|
||||
if not session_key:
|
||||
return
|
||||
|
||||
@@ -8623,6 +8896,10 @@ class GatewayRunner:
|
||||
if isinstance(pending_approvals, dict):
|
||||
pending_approvals.pop(session_key, None)
|
||||
|
||||
update_prompt_pending = getattr(self, "_update_prompt_pending", None)
|
||||
if isinstance(update_prompt_pending, dict):
|
||||
update_prompt_pending.pop(session_key, None)
|
||||
|
||||
try:
|
||||
from tools.approval import clear_session as _clear_approval_session
|
||||
except Exception:
|
||||
@@ -8722,6 +8999,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.
|
||||
|
||||
@@ -9009,11 +9305,21 @@ class GatewayRunner:
|
||||
if source.platform == Platform.MATRIX:
|
||||
_effective_cursor = ""
|
||||
_buffer_only = True
|
||||
# Fresh-final applies to Telegram only — other
|
||||
# platforms either edit in place cheaply (Discord,
|
||||
# Slack) or don't have the timestamp-on-edit
|
||||
# problem. (Ported from openclaw/openclaw#72038.)
|
||||
_fresh_final_secs = (
|
||||
float(getattr(_scfg, "fresh_final_after_seconds", 0.0) or 0.0)
|
||||
if source.platform == Platform.TELEGRAM
|
||||
else 0.0
|
||||
)
|
||||
_consumer_cfg = StreamConsumerConfig(
|
||||
edit_interval=_scfg.edit_interval,
|
||||
buffer_threshold=_scfg.buffer_threshold,
|
||||
cursor=_effective_cursor,
|
||||
buffer_only=_buffer_only,
|
||||
fresh_final_after_seconds=_fresh_final_secs,
|
||||
)
|
||||
_stream_consumer = GatewayStreamConsumer(
|
||||
adapter=_adapter,
|
||||
@@ -9697,11 +10003,21 @@ class GatewayRunner:
|
||||
if source.platform == Platform.MATRIX:
|
||||
_effective_cursor = ""
|
||||
_buffer_only = True
|
||||
# Fresh-final applies to Telegram only — other
|
||||
# platforms either edit in place cheaply or don't
|
||||
# have the edit-timestamp-stays-stale problem.
|
||||
# (Ported from openclaw/openclaw#72038.)
|
||||
_fresh_final_secs = (
|
||||
float(getattr(_scfg, "fresh_final_after_seconds", 0.0) or 0.0)
|
||||
if source.platform == Platform.TELEGRAM
|
||||
else 0.0
|
||||
)
|
||||
_consumer_cfg = StreamConsumerConfig(
|
||||
edit_interval=_scfg.edit_interval,
|
||||
buffer_threshold=_scfg.buffer_threshold,
|
||||
cursor=_effective_cursor,
|
||||
buffer_only=_buffer_only,
|
||||
fresh_final_after_seconds=_fresh_final_secs,
|
||||
)
|
||||
_stream_consumer = GatewayStreamConsumer(
|
||||
adapter=_adapter,
|
||||
@@ -9766,12 +10082,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 +10865,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 +11166,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)
|
||||
|
||||
|
||||
+11
-2
@@ -310,8 +310,9 @@ def build_session_context_prompt(
|
||||
"**Platform notes:** You are running inside Slack. "
|
||||
"You do NOT have access to Slack-specific APIs — you cannot search "
|
||||
"channel history, pin/unpin messages, manage channels, or list users. "
|
||||
"Do not promise to perform these actions. If the user asks, explain "
|
||||
"that you can only read messages sent directly to you and respond."
|
||||
"Do not promise to perform these actions. The gateway may inline the "
|
||||
"current message's Slack block/attachment payload when available, but "
|
||||
"you still cannot call Slack APIs yourself."
|
||||
)
|
||||
elif context.source.platform == Platform.DISCORD:
|
||||
# Inject the Discord IDs block only when the agent actually has
|
||||
@@ -353,6 +354,14 @@ def build_session_context_prompt(
|
||||
"If the user needs a detailed answer, give the short version first "
|
||||
"and offer to elaborate."
|
||||
)
|
||||
elif context.source.platform == Platform.YUANBAO:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"**Platform notes:** You are running inside Yuanbao. "
|
||||
"You CAN send private (DM) messages via the send_message tool. "
|
||||
"Use target='yuanbao:direct:<account_id>' for DM "
|
||||
"and target='yuanbao:group:<group_code>' for group chat."
|
||||
)
|
||||
|
||||
# Connected platforms
|
||||
platforms_list = ["local (files on this machine)"]
|
||||
|
||||
@@ -44,6 +44,14 @@ class StreamConsumerConfig:
|
||||
buffer_threshold: int = 40
|
||||
cursor: str = " ▉"
|
||||
buffer_only: bool = False
|
||||
# When >0, the final edit for a streamed response is delivered as a
|
||||
# fresh message if the original preview has been visible for at least
|
||||
# this many seconds. This makes the platform's visible timestamp
|
||||
# reflect completion time instead of first-token time for long-running
|
||||
# responses (e.g. reasoning models that stream slowly). Ported from
|
||||
# openclaw/openclaw#72038. Default 0 = always edit in place (legacy
|
||||
# behavior). The gateway enables this selectively per-platform.
|
||||
fresh_final_after_seconds: float = 0.0
|
||||
|
||||
|
||||
class GatewayStreamConsumer:
|
||||
@@ -91,6 +99,12 @@ class GatewayStreamConsumer:
|
||||
self._queue: queue.Queue = queue.Queue()
|
||||
self._accumulated = ""
|
||||
self._message_id: Optional[str] = None
|
||||
# Wall-clock timestamp (time.monotonic) when ``_message_id`` was
|
||||
# first assigned from a successful first-send. Used by the
|
||||
# fresh-final logic to detect long-lived previews whose edit
|
||||
# timestamps would be stale by completion time. Ported from
|
||||
# openclaw/openclaw#72038.
|
||||
self._message_created_ts: Optional[float] = None
|
||||
self._already_sent = False
|
||||
self._edit_supported = True # Disabled when progressive edits are no longer usable
|
||||
self._last_edit_time = 0.0
|
||||
@@ -136,6 +150,7 @@ class GatewayStreamConsumer:
|
||||
if preserve_no_edit and self._message_id == "__no_edit__":
|
||||
return
|
||||
self._message_id = None
|
||||
self._message_created_ts = None
|
||||
self._accumulated = ""
|
||||
self._last_sent_text = ""
|
||||
self._fallback_final_send = False
|
||||
@@ -734,6 +749,81 @@ class GatewayStreamConsumer:
|
||||
logger.error("Commentary send error: %s", e)
|
||||
return False
|
||||
|
||||
def _should_send_fresh_final(self) -> bool:
|
||||
"""Return True when a long-lived preview should be replaced with a
|
||||
fresh final message instead of an edit.
|
||||
|
||||
Conditions:
|
||||
- Fresh-final is enabled (``fresh_final_after_seconds > 0``).
|
||||
- We have a real preview message id (not the ``__no_edit__`` sentinel
|
||||
and not ``None``).
|
||||
- The preview has been visible for at least the configured threshold.
|
||||
|
||||
Ported from openclaw/openclaw#72038.
|
||||
"""
|
||||
threshold = getattr(self.cfg, "fresh_final_after_seconds", 0.0) or 0.0
|
||||
if threshold <= 0:
|
||||
return False
|
||||
if not self._message_id or self._message_id == "__no_edit__":
|
||||
return False
|
||||
if self._message_created_ts is None:
|
||||
return False
|
||||
age = time.monotonic() - self._message_created_ts
|
||||
return age >= threshold
|
||||
|
||||
async def _try_fresh_final(self, text: str) -> bool:
|
||||
"""Send ``text`` as a brand-new message (best-effort delete the old
|
||||
preview) so the platform's visible timestamp reflects completion
|
||||
time. Returns True on successful delivery, False on any failure so
|
||||
the caller falls back to the normal edit path.
|
||||
|
||||
Ported from openclaw/openclaw#72038.
|
||||
"""
|
||||
old_message_id = self._message_id
|
||||
try:
|
||||
result = await self.adapter.send(
|
||||
chat_id=self.chat_id,
|
||||
content=text,
|
||||
metadata=self.metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Fresh-final send failed, falling back to edit: %s", e)
|
||||
return False
|
||||
if not getattr(result, "success", False):
|
||||
return False
|
||||
# Successful fresh send — try to delete the stale preview so the
|
||||
# user doesn't see the old edit-stuck message underneath. Cleanup
|
||||
# is best-effort; platforms that don't implement ``delete_message``
|
||||
# just leave the preview behind (still an acceptable outcome —
|
||||
# the visible final timestamp is the important part).
|
||||
if old_message_id and old_message_id != "__no_edit__":
|
||||
delete_fn = getattr(self.adapter, "delete_message", None)
|
||||
if delete_fn is not None:
|
||||
try:
|
||||
await delete_fn(self.chat_id, old_message_id)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Fresh-final preview cleanup failed (%s): %s",
|
||||
old_message_id, e,
|
||||
)
|
||||
# Adopt the new message id as the current message so subsequent
|
||||
# callers (e.g. overflow split loops, finalize retries) see a
|
||||
# consistent state.
|
||||
new_message_id = getattr(result, "message_id", None)
|
||||
if new_message_id:
|
||||
self._message_id = new_message_id
|
||||
self._message_created_ts = time.monotonic()
|
||||
else:
|
||||
# Send succeeded but platform didn't return an id — treat the
|
||||
# delivery as final-only and fall back to "__no_edit__" so we
|
||||
# don't try to edit something we can't address.
|
||||
self._message_id = "__no_edit__"
|
||||
self._message_created_ts = None
|
||||
self._already_sent = True
|
||||
self._last_sent_text = text
|
||||
self._final_response_sent = True
|
||||
return True
|
||||
|
||||
async def _send_or_edit(self, text: str, *, finalize: bool = False) -> bool:
|
||||
"""Send or edit the streaming message.
|
||||
|
||||
@@ -786,6 +876,22 @@ class GatewayStreamConsumer:
|
||||
finalize and self._adapter_requires_finalize
|
||||
):
|
||||
return True
|
||||
# Fresh-final for long-lived previews: when finalizing
|
||||
# the last edit in a streaming sequence, if the
|
||||
# original preview has been visible for at least
|
||||
# ``fresh_final_after_seconds``, send the completed
|
||||
# reply as a fresh message so the platform's visible
|
||||
# timestamp reflects completion time instead of the
|
||||
# preview creation time. Best-effort cleanup of the
|
||||
# old preview follows. Ported from
|
||||
# openclaw/openclaw#72038. Gated by config so the
|
||||
# legacy edit-in-place path stays the default.
|
||||
if (
|
||||
finalize
|
||||
and self._should_send_fresh_final()
|
||||
and await self._try_fresh_final(text)
|
||||
):
|
||||
return True
|
||||
# Edit existing message
|
||||
result = await self.adapter.edit_message(
|
||||
chat_id=self.chat_id,
|
||||
@@ -852,6 +958,10 @@ class GatewayStreamConsumer:
|
||||
if result.success:
|
||||
if result.message_id:
|
||||
self._message_id = result.message_id
|
||||
# Track when the preview first became visible to
|
||||
# the user so fresh-final logic can detect stale
|
||||
# preview timestamps on long-running responses.
|
||||
self._message_created_ts = time.monotonic()
|
||||
else:
|
||||
self._edit_supported = False
|
||||
self._already_sent = True
|
||||
|
||||
@@ -31,8 +31,17 @@ Hermes' own session keys.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# WhatsApp JIDs are numeric (or plus-prefixed numeric) with optional
|
||||
# ``@``, ``.`` and ``:`` separators. ``\w`` is pinned to ASCII so
|
||||
# full-width digits / Unicode word chars can't sneak through.
|
||||
_SAFE_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9@.+\-]+$")
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
|
||||
@@ -81,6 +90,16 @@ def expand_whatsapp_aliases(identifier: str) -> Set[str]:
|
||||
current = queue.pop(0)
|
||||
if not current or current in resolved:
|
||||
continue
|
||||
# Defense-in-depth: reject identifiers that could sneak path
|
||||
# separators / traversal segments into the ``lid-mapping-{current}``
|
||||
# filename below. The hardcoded ``lid-mapping-`` prefix already
|
||||
# prevents escape via pathlib's component split (an attacker can't
|
||||
# create ``lid-mapping-..`` as a real directory in session_dir), but
|
||||
# this keeps the identifier space to the characters WhatsApp JIDs
|
||||
# actually use and avoids depending on that filesystem-layout
|
||||
# invariant.
|
||||
if not _SAFE_IDENTIFIER_RE.match(current):
|
||||
continue
|
||||
|
||||
resolved.add(current)
|
||||
for suffix in ("", "_reverse"):
|
||||
@@ -91,7 +110,8 @@ def expand_whatsapp_aliases(identifier: str) -> Set[str]:
|
||||
mapped = normalize_whatsapp_identifier(
|
||||
json.loads(mapping_path.read_text(encoding="utf-8"))
|
||||
)
|
||||
except Exception:
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.debug("whatsapp_identity: failed to read %s: %s", mapping_path, exc)
|
||||
continue
|
||||
if mapped and mapped not in resolved:
|
||||
queue.append(mapped)
|
||||
|
||||
+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 "", ""
|
||||
|
||||
|
||||
|
||||
+110
-2
@@ -126,8 +126,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("voice", "Toggle voice mode", "Configuration",
|
||||
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
|
||||
CommandDef("busy", "Control what Enter does while Hermes is working", "Configuration",
|
||||
cli_only=True, args_hint="[queue|interrupt|status]",
|
||||
subcommands=("queue", "interrupt", "status")),
|
||||
cli_only=True, args_hint="[queue|steer|interrupt|status]",
|
||||
subcommands=("queue", "steer", "interrupt", "status")),
|
||||
|
||||
# Tools & Skills
|
||||
CommandDef("tools", "Manage tools: /tools [list|disable|enable] [name...]", "Tools & Skills",
|
||||
@@ -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.
|
||||
|
||||
|
||||
+53
-1
@@ -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
|
||||
@@ -486,6 +487,19 @@ DEFAULT_CONFIG = {
|
||||
"checkpoints": {
|
||||
"enabled": True,
|
||||
"max_snapshots": 50, # Max checkpoints to keep per directory
|
||||
# Auto-maintenance: shadow repos accumulate forever under
|
||||
# ~/.hermes/checkpoints/ (one per cd'd working directory). Field
|
||||
# reports put the typical offender at 1000+ repos / ~12 GB. When
|
||||
# auto_prune is on, hermes sweeps at startup (at most once per
|
||||
# min_interval_hours) and deletes:
|
||||
# * orphan repos: HERMES_WORKDIR no longer exists on disk
|
||||
# * stale repos: newest mtime older than retention_days
|
||||
# Opt-in so users who rely on /rollback against long-ago sessions
|
||||
# never lose data silently.
|
||||
"auto_prune": False,
|
||||
"retention_days": 7,
|
||||
"delete_orphans": True,
|
||||
"min_interval_hours": 24,
|
||||
},
|
||||
|
||||
# Maximum characters returned by a single read_file call. Reads that
|
||||
@@ -626,7 +640,7 @@ DEFAULT_CONFIG = {
|
||||
"compact": False,
|
||||
"personality": "kawaii",
|
||||
"resume_display": "full",
|
||||
"busy_input_mode": "interrupt",
|
||||
"busy_input_mode": "interrupt", # interrupt | queue | steer
|
||||
"bell_on_complete": False,
|
||||
"show_reasoning": False,
|
||||
"streaming": False,
|
||||
@@ -1581,6 +1595,44 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "tool",
|
||||
},
|
||||
|
||||
# ── Bundled skills (opt-in: only needed if the user uses that skill) ──
|
||||
# These use category="skill" (distinct from "tool") so the sandbox
|
||||
# env blocklist in tools/environments/local.py does NOT rewrite them —
|
||||
# skills legitimately need these passed through to curl via
|
||||
# tools/env_passthrough.py when the user's skill calls out.
|
||||
"NOTION_API_KEY": {
|
||||
"description": "Notion integration token (used by the `notion` skill)",
|
||||
"prompt": "Notion API key",
|
||||
"url": "https://www.notion.so/my-integrations",
|
||||
"password": True,
|
||||
"category": "skill",
|
||||
"advanced": True,
|
||||
},
|
||||
"LINEAR_API_KEY": {
|
||||
"description": "Linear personal API key (used by the `linear` skill)",
|
||||
"prompt": "Linear API key",
|
||||
"url": "https://linear.app/settings/api",
|
||||
"password": True,
|
||||
"category": "skill",
|
||||
"advanced": True,
|
||||
},
|
||||
"AIRTABLE_API_KEY": {
|
||||
"description": "Airtable personal access token (used by the `airtable` skill)",
|
||||
"prompt": "Airtable API key",
|
||||
"url": "https://airtable.com/create/tokens",
|
||||
"password": True,
|
||||
"category": "skill",
|
||||
"advanced": True,
|
||||
},
|
||||
"TENOR_API_KEY": {
|
||||
"description": "Tenor API key for GIF search (used by the `gif-search` skill)",
|
||||
"prompt": "Tenor API key",
|
||||
"url": "https://developers.google.com/tenor/guides/quickstart",
|
||||
"password": True,
|
||||
"category": "skill",
|
||||
"advanced": True,
|
||||
},
|
||||
|
||||
# ── Honcho ──
|
||||
"HONCHO_API_KEY": {
|
||||
"description": "Honcho API key for AI-native persistent memory",
|
||||
|
||||
@@ -2724,6 +2724,24 @@ _PLATFORMS = [
|
||||
"help": "OpenID to deliver cron results and notifications to."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "yuanbao",
|
||||
"label": "Yuanbao",
|
||||
"emoji": "💎",
|
||||
"token_var": "YUANBAO_APP_ID",
|
||||
"setup_instructions": [
|
||||
"1. Download the Yuanbao app from https://yuanbao.tencent.com/",
|
||||
"2. In the app, go to PAI → My Bot and create a new bot",
|
||||
"3. After the bot is created, copy the App ID and App Secret",
|
||||
"4. Enter them below and Hermes will connect automatically over WebSocket",
|
||||
],
|
||||
"vars": [
|
||||
{"name": "YUANBAO_APP_ID", "prompt": "App ID", "password": False,
|
||||
"help": "The App ID from your Yuanbao IM Bot credentials."},
|
||||
{"name": "YUANBAO_APP_SECRET", "prompt": "App Secret", "password": True,
|
||||
"help": "The App Secret (used for HMAC signing) from your Yuanbao IM Bot."},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -3108,6 +3126,12 @@ def _setup_wecom():
|
||||
print_success("💬 WeCom configured!")
|
||||
|
||||
|
||||
def _setup_yuanbao():
|
||||
"""Configure Yuanbao via the standard platform setup."""
|
||||
yuanbao_platform = next(p for p in _PLATFORMS if p["key"] == "yuanbao")
|
||||
_setup_standard_platform(yuanbao_platform)
|
||||
|
||||
|
||||
def _is_service_installed() -> bool:
|
||||
"""Check if the gateway is installed as a system service."""
|
||||
if supports_systemd_services():
|
||||
|
||||
+281
-13
@@ -1043,6 +1043,7 @@ def _launch_tui(
|
||||
)
|
||||
env.setdefault("HERMES_PYTHON", sys.executable)
|
||||
env.setdefault("HERMES_CWD", os.getcwd())
|
||||
env.setdefault("NODE_ENV", "development" if tui_dev else "production")
|
||||
if model:
|
||||
env["HERMES_MODEL"] = model
|
||||
env["HERMES_INFERENCE_MODEL"] = model
|
||||
@@ -4412,8 +4413,14 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
from hermes_cli.models import fetch_ollama_cloud_models
|
||||
|
||||
api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else "")
|
||||
# During setup, force a live refresh so the picker reflects newly
|
||||
# released models (e.g. deepseek v4 flash, kimi k2.6) the moment
|
||||
# the user enters their key — not an hour later when the disk
|
||||
# cache TTL expires.
|
||||
model_list = fetch_ollama_cloud_models(
|
||||
api_key=api_key_for_probe, base_url=effective_base
|
||||
api_key=api_key_for_probe,
|
||||
base_url=effective_base,
|
||||
force_refresh=True,
|
||||
)
|
||||
if model_list:
|
||||
print(f" Found {len(model_list)} model(s) from Ollama Cloud")
|
||||
@@ -4780,6 +4787,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
|
||||
@@ -4953,6 +4991,83 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0)
|
||||
return default
|
||||
|
||||
|
||||
def _web_ui_build_needed(web_dir: Path) -> bool:
|
||||
"""Return True if the web UI dist is missing or stale.
|
||||
|
||||
Mirrors the staleness logic used by ``_tui_build_needed()`` for the TUI.
|
||||
The Vite build outputs to ``hermes_cli/web_dist/`` (per vite.config.ts
|
||||
outDir: "../hermes_cli/web_dist"), NOT to ``web/dist/``. Uses the Vite
|
||||
manifest as the sentinel because it is written last and therefore has the
|
||||
newest mtime of any build output.
|
||||
"""
|
||||
dist_dir = web_dir.parent / "hermes_cli" / "web_dist"
|
||||
sentinel = dist_dir / ".vite" / "manifest.json"
|
||||
if not sentinel.exists():
|
||||
sentinel = dist_dir / "index.html"
|
||||
if not sentinel.exists():
|
||||
return True
|
||||
dist_mtime = sentinel.stat().st_mtime
|
||||
skip = frozenset({"node_modules", "dist"})
|
||||
for dirpath, dirnames, filenames in os.walk(web_dir, topdown=True):
|
||||
dirnames[:] = [d for d in dirnames if d not in skip]
|
||||
for fn in filenames:
|
||||
if fn.endswith((".ts", ".tsx", ".js", ".jsx", ".css", ".html", ".vue")):
|
||||
if os.path.getmtime(os.path.join(dirpath, fn)) > dist_mtime:
|
||||
return True
|
||||
for meta in (
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"yarn.lock",
|
||||
"pnpm-lock.yaml",
|
||||
"vite.config.ts",
|
||||
"vite.config.js",
|
||||
):
|
||||
mp = web_dir / meta
|
||||
if mp.exists() and mp.stat().st_mtime > dist_mtime:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _run_npm_install_deterministic(
|
||||
npm: str,
|
||||
cwd: Path,
|
||||
*,
|
||||
extra_args: tuple[str, ...] = (),
|
||||
capture_output: bool = True,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a deterministic npm install that does not mutate ``package-lock.json``.
|
||||
|
||||
Prefers ``npm ci`` (strict, lockfile-preserving) when a lockfile is present;
|
||||
falls back to ``npm install`` only if ``npm ci`` fails (e.g. lockfile out of
|
||||
sync on a WIP checkout). Without this, ``npm install`` on npm ≥ 10 silently
|
||||
rewrites committed lockfiles (stripping ``"peer": true`` etc.), which leaves
|
||||
the working tree dirty and causes the next ``hermes update`` to stash the
|
||||
lockfile — repeatedly.
|
||||
"""
|
||||
lockfile = cwd / "package-lock.json"
|
||||
if lockfile.exists():
|
||||
ci_cmd = [npm, "ci", *extra_args]
|
||||
ci_result = subprocess.run(
|
||||
ci_cmd,
|
||||
cwd=cwd,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if ci_result.returncode == 0:
|
||||
return ci_result
|
||||
# Fall through to `npm install` — lockfile may be out of sync on a
|
||||
# WIP fork/branch, or `npm ci` may not be available on very old npm.
|
||||
install_cmd = [npm, "install", *extra_args]
|
||||
return subprocess.run(
|
||||
install_cmd,
|
||||
cwd=cwd,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
"""Build the web UI frontend if npm is available.
|
||||
|
||||
@@ -4966,6 +5081,9 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
if not (web_dir / "package.json").exists():
|
||||
return True
|
||||
|
||||
if not _web_ui_build_needed(web_dir):
|
||||
return True
|
||||
|
||||
npm = shutil.which("npm")
|
||||
if not npm:
|
||||
if fatal:
|
||||
@@ -4973,7 +5091,7 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
print("Install Node.js, then run: cd web && npm install && npm run build")
|
||||
return not fatal
|
||||
print("→ Building web UI...")
|
||||
r1 = subprocess.run([npm, "install", "--silent"], cwd=web_dir, capture_output=True)
|
||||
r1 = _run_npm_install_deterministic(npm, web_dir, extra_args=("--silent",))
|
||||
if r1.returncode != 0:
|
||||
print(
|
||||
f" {'✗' if fatal else '⚠'} Web UI npm install failed"
|
||||
@@ -5684,12 +5802,10 @@ def _update_node_dependencies() -> None:
|
||||
if not (path / "package.json").exists():
|
||||
continue
|
||||
|
||||
result = subprocess.run(
|
||||
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
|
||||
cwd=path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
result = _run_npm_install_deterministic(
|
||||
npm,
|
||||
path,
|
||||
extra_args=("--silent", "--no-fund", "--no-audit", "--progress=false"),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(f" ✓ {label}")
|
||||
@@ -5925,6 +6041,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 +6566,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 +8003,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
|
||||
# =========================================================================
|
||||
@@ -8429,11 +8682,17 @@ Examples:
|
||||
|
||||
skills_install = skills_subparsers.add_parser("install", help="Install a skill")
|
||||
skills_install.add_argument(
|
||||
"identifier", help="Skill identifier (e.g. openai/skills/skill-creator)"
|
||||
"identifier",
|
||||
help="Skill identifier (e.g. openai/skills/skill-creator) or a direct HTTP(S) URL to a SKILL.md file",
|
||||
)
|
||||
skills_install.add_argument(
|
||||
"--category", default="", help="Category folder to install into"
|
||||
)
|
||||
skills_install.add_argument(
|
||||
"--name",
|
||||
default="",
|
||||
help="Override the skill name (useful when installing from a URL whose SKILL.md has no `name:` frontmatter)",
|
||||
)
|
||||
skills_install.add_argument(
|
||||
"--force", action="store_true", help="Install despite blocked scan verdict"
|
||||
)
|
||||
@@ -8453,6 +8712,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"
|
||||
@@ -8959,7 +9224,7 @@ Examples:
|
||||
"--source", help="Filter by source (cli, telegram, discord, etc.)"
|
||||
)
|
||||
sessions_browse.add_argument(
|
||||
"--limit", type=int, default=50, help="Max sessions to load (default: 50)"
|
||||
"--limit", type=int, default=500, help="Max sessions to load (default: 500)"
|
||||
)
|
||||
|
||||
def _confirm_prompt(prompt: str) -> bool:
|
||||
@@ -9056,7 +9321,8 @@ Examples:
|
||||
):
|
||||
print("Cancelled.")
|
||||
return
|
||||
if db.delete_session(resolved_session_id):
|
||||
sessions_dir = get_hermes_home() / "sessions"
|
||||
if db.delete_session(resolved_session_id, sessions_dir=sessions_dir):
|
||||
print(f"Deleted session '{resolved_session_id}'.")
|
||||
else:
|
||||
print(f"Session '{args.session_id}' not found.")
|
||||
@@ -9070,7 +9336,9 @@ Examples:
|
||||
):
|
||||
print("Cancelled.")
|
||||
return
|
||||
count = db.prune_sessions(older_than_days=days, source=args.source)
|
||||
sessions_dir = get_hermes_home() / "sessions"
|
||||
count = db.prune_sessions(older_than_days=days, source=args.source,
|
||||
sessions_dir=sessions_dir)
|
||||
print(f"Pruned {count} session(s).")
|
||||
|
||||
elif action == "rename":
|
||||
@@ -9088,7 +9356,7 @@ Examples:
|
||||
print(f"Error: {e}")
|
||||
|
||||
elif action == "browse":
|
||||
limit = getattr(args, "limit", 50) or 50
|
||||
limit = getattr(args, "limit", 500) or 500
|
||||
source = getattr(args, "source", None)
|
||||
_browse_exclude = None if source else ["tool"]
|
||||
sessions = db.list_sessions_rich(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Dict, Iterable, Optional, Set
|
||||
from hermes_cli.auth import get_nous_auth_status
|
||||
from hermes_cli.config import get_env_value, load_config
|
||||
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
|
||||
from utils import is_truthy_value
|
||||
from tools.tool_backend_helpers import (
|
||||
fal_key_is_configured,
|
||||
has_direct_modal_credentials,
|
||||
@@ -25,6 +26,13 @@ _DEFAULT_PLATFORM_TOOLSETS = {
|
||||
}
|
||||
|
||||
|
||||
def _uses_gateway(section: object) -> bool:
|
||||
"""Return True when a config section explicitly opts into the gateway."""
|
||||
if not isinstance(section, dict):
|
||||
return False
|
||||
return is_truthy_value(section.get("use_gateway"), default=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NousFeatureState:
|
||||
key: str
|
||||
@@ -262,11 +270,11 @@ def get_nous_subscription_features(
|
||||
# use_gateway flags — when True, the user explicitly opted into the
|
||||
# Tool Gateway via `hermes model`, so direct credentials should NOT
|
||||
# prevent gateway routing.
|
||||
web_use_gateway = bool(web_cfg.get("use_gateway"))
|
||||
tts_use_gateway = bool(tts_cfg.get("use_gateway"))
|
||||
browser_use_gateway = bool(browser_cfg.get("use_gateway"))
|
||||
web_use_gateway = _uses_gateway(web_cfg)
|
||||
tts_use_gateway = _uses_gateway(tts_cfg)
|
||||
browser_use_gateway = _uses_gateway(browser_cfg)
|
||||
image_gen_cfg = config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {}
|
||||
image_use_gateway = bool(image_gen_cfg.get("use_gateway"))
|
||||
image_use_gateway = _uses_gateway(image_gen_cfg)
|
||||
|
||||
direct_exa = bool(get_env_value("EXA_API_KEY"))
|
||||
direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"))
|
||||
@@ -601,10 +609,10 @@ def get_gateway_eligible_tools(
|
||||
# no direct keys exist — we only skip the prompt for tools where
|
||||
# use_gateway was explicitly set.
|
||||
opted_in = {
|
||||
"web": bool((config.get("web") if isinstance(config.get("web"), dict) else {}).get("use_gateway")),
|
||||
"image_gen": bool((config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {}).get("use_gateway")),
|
||||
"tts": bool((config.get("tts") if isinstance(config.get("tts"), dict) else {}).get("use_gateway")),
|
||||
"browser": bool((config.get("browser") if isinstance(config.get("browser"), dict) else {}).get("use_gateway")),
|
||||
"web": _uses_gateway(config.get("web")),
|
||||
"image_gen": _uses_gateway(config.get("image_gen")),
|
||||
"tts": _uses_gateway(config.get("tts")),
|
||||
"browser": _uses_gateway(config.get("browser")),
|
||||
}
|
||||
|
||||
unconfigured: list[str] = []
|
||||
|
||||
@@ -36,6 +36,7 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([
|
||||
("wecom_callback", PlatformInfo(label="💬 WeCom Callback", default_toolset="hermes-wecom-callback")),
|
||||
("weixin", PlatformInfo(label="💬 Weixin", default_toolset="hermes-weixin")),
|
||||
("qqbot", PlatformInfo(label="💬 QQBot", default_toolset="hermes-qqbot")),
|
||||
("yuanbao", PlatformInfo(label="🤖 Yuanbao", default_toolset="hermes-yuanbao")),
|
||||
("webhook", PlatformInfo(label="🔗 Webhook", default_toolset="hermes-webhook")),
|
||||
("api_server", PlatformInfo(label="🌐 API Server", default_toolset="hermes-api-server")),
|
||||
("cron", PlatformInfo(label="⏰ Cron", default_toolset="hermes-cron")),
|
||||
|
||||
+69
-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")
|
||||
@@ -2085,6 +2133,12 @@ def _setup_feishu():
|
||||
_gateway_setup_feishu()
|
||||
|
||||
|
||||
def _setup_yuanbao():
|
||||
"""Configure Yuanbao via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_yuanbao as _gateway_setup_yuanbao
|
||||
_gateway_setup_yuanbao()
|
||||
|
||||
|
||||
def _setup_wecom():
|
||||
"""Configure WeCom (Enterprise WeChat) via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_wecom as _gateway_setup_wecom
|
||||
@@ -2229,6 +2283,7 @@ _GATEWAY_PLATFORMS = [
|
||||
("WhatsApp", "WHATSAPP_ENABLED", _setup_whatsapp),
|
||||
("DingTalk", "DINGTALK_CLIENT_ID", _setup_dingtalk),
|
||||
("Feishu / Lark", "FEISHU_APP_ID", _setup_feishu),
|
||||
("Yuanbao", "YUANBAO_APP_ID", _setup_yuanbao),
|
||||
("WeCom (Enterprise WeChat)", "WECOM_BOT_ID", _setup_wecom),
|
||||
("WeCom Callback (Self-Built App)", "WECOM_CALLBACK_CORP_ID", _setup_wecom_callback),
|
||||
("Weixin (WeChat)", "WEIXIN_ACCOUNT_ID", _setup_weixin),
|
||||
|
||||
+230
-20
@@ -11,9 +11,10 @@ handler are thin wrappers that parse args and delegate.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
@@ -141,6 +142,103 @@ def _derive_category_from_install_path(install_path: str) -> str:
|
||||
return "" if parent == "." else parent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive name/category resolution for URL-installed skills
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
|
||||
_VALID_CATEGORY_RE = re.compile(r"^[a-z][a-z0-9_/-]*$")
|
||||
|
||||
|
||||
def _is_valid_installed_skill_name(name: str) -> bool:
|
||||
"""Accept identifier-shaped names, reject empty / sentinel-y values."""
|
||||
if not isinstance(name, str):
|
||||
return False
|
||||
candidate = name.strip().lower()
|
||||
if not candidate or candidate in {"skill", "readme", "index", "unnamed-skill"}:
|
||||
return False
|
||||
return bool(_VALID_NAME_RE.match(candidate))
|
||||
|
||||
|
||||
def _existing_categories() -> List[str]:
|
||||
"""Return sorted subdirectory names under ``~/.hermes/skills/`` that look
|
||||
like category buckets (contain at least one ``SKILL.md`` somewhere below).
|
||||
|
||||
Used to suggest reusable categories when interactively installing from a
|
||||
URL. Hidden dirs (``.hub``, ``.trash``) are skipped.
|
||||
"""
|
||||
from tools.skills_hub import SKILLS_DIR
|
||||
out: List[str] = []
|
||||
try:
|
||||
for entry in SKILLS_DIR.iterdir():
|
||||
if not entry.is_dir() or entry.name.startswith("."):
|
||||
continue
|
||||
# Only count as a category if it contains skills, not if it IS a skill.
|
||||
# Heuristic: if ``<entry>/SKILL.md`` exists, it's a skill at the
|
||||
# top level (no category); otherwise treat as a category bucket.
|
||||
if (entry / "SKILL.md").exists():
|
||||
continue
|
||||
# Has at least one nested SKILL.md?
|
||||
try:
|
||||
if any(entry.rglob("SKILL.md")):
|
||||
out.append(entry.name)
|
||||
except OSError:
|
||||
continue
|
||||
except (FileNotFoundError, OSError):
|
||||
return []
|
||||
return sorted(set(out))
|
||||
|
||||
|
||||
def _prompt_for_skill_name(c: Console, url: str, default: str = "") -> Optional[str]:
|
||||
"""Prompt interactively for a skill name. Returns None on cancel/EOF."""
|
||||
c.print()
|
||||
c.print(
|
||||
f"[yellow]The SKILL.md at {url} doesn't declare a `name:` in its "
|
||||
f"frontmatter,[/]\n[yellow]and the URL path doesn't produce a valid "
|
||||
f"identifier either.[/]"
|
||||
)
|
||||
default_hint = f" [{default}]" if default else ""
|
||||
c.print(
|
||||
f"[bold]Enter a skill name{default_hint}:[/] "
|
||||
f"[dim](lowercase letters, digits, hyphens, underscores; starts with a letter)[/]"
|
||||
)
|
||||
try:
|
||||
answer = input("Name: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
if not answer and default:
|
||||
answer = default
|
||||
if not _is_valid_installed_skill_name(answer):
|
||||
c.print(f"[bold red]Invalid name:[/] {answer!r}. Aborting install.\n")
|
||||
return None
|
||||
return answer
|
||||
|
||||
|
||||
def _prompt_for_category(c: Console, existing: List[str]) -> str:
|
||||
"""Prompt interactively for a category. Empty/None input means flat install."""
|
||||
c.print()
|
||||
if existing:
|
||||
c.print(
|
||||
"[bold]Pick a category[/] "
|
||||
"[dim](reuse an existing bucket, type a new one, or press Enter to install flat)[/]"
|
||||
)
|
||||
c.print(f"[dim]Existing: {', '.join(existing)}[/]")
|
||||
else:
|
||||
c.print(
|
||||
"[bold]Category[/] [dim](optional — press Enter to install flat at ~/.hermes/skills/<name>/)[/]"
|
||||
)
|
||||
try:
|
||||
answer = input("Category: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return ""
|
||||
if not answer:
|
||||
return ""
|
||||
if not _VALID_CATEGORY_RE.match(answer):
|
||||
c.print(f"[dim]Invalid category {answer!r} — installing flat.[/]")
|
||||
return ""
|
||||
return answer
|
||||
|
||||
|
||||
def do_search(query: str, source: str = "all", limit: int = 10,
|
||||
console: Optional[Console] = None) -> None:
|
||||
"""Search registries and display results as a Rich table."""
|
||||
@@ -309,8 +407,17 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
||||
|
||||
def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
console: Optional[Console] = None, skip_confirm: bool = False,
|
||||
invalidate_cache: bool = True) -> None:
|
||||
"""Fetch, quarantine, scan, confirm, and install a skill."""
|
||||
invalidate_cache: bool = True,
|
||||
name_override: str = "") -> None:
|
||||
"""Fetch, quarantine, scan, confirm, and install a skill.
|
||||
|
||||
``name_override`` lets non-interactive callers (slash commands, gateway,
|
||||
scripts) supply a skill name when the upstream SKILL.md lacks a valid
|
||||
``name:`` frontmatter field. On interactive TTY surfaces, a missing name
|
||||
triggers a prompt instead; ``skip_confirm=True`` means "non-interactive"
|
||||
(so pair it with ``name_override`` when installing from a URL that has
|
||||
no frontmatter).
|
||||
"""
|
||||
from tools.skills_hub import (
|
||||
GitHubAuth, create_source_router, ensure_hub_dirs,
|
||||
quarantine_bundle, install_from_quarantine, HubLockFile,
|
||||
@@ -354,6 +461,58 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
c.print()
|
||||
return
|
||||
|
||||
# URL-sourced skills may arrive with an empty name when SKILL.md has no
|
||||
# ``name:`` in frontmatter AND the URL path doesn't yield a valid
|
||||
# identifier. Resolve by (1) --name override, (2) interactive prompt on
|
||||
# a TTY, (3) refuse with an actionable error on non-interactive surfaces.
|
||||
bundle_meta = getattr(bundle, "metadata", {}) or {}
|
||||
if bundle.source == "url" and (not bundle.name or bundle_meta.get("awaiting_name")):
|
||||
if name_override and _is_valid_installed_skill_name(name_override):
|
||||
bundle.name = name_override.strip()
|
||||
bundle_meta["awaiting_name"] = False
|
||||
elif name_override:
|
||||
c.print(
|
||||
f"[bold red]Invalid --name:[/] {name_override!r}. "
|
||||
"Must be a lowercase identifier (letters, digits, hyphens, "
|
||||
"underscores; starts with a letter).\n"
|
||||
)
|
||||
return
|
||||
elif skip_confirm:
|
||||
# Non-interactive surface (slash command / TUI / gateway). Can't
|
||||
# prompt — emit an actionable error.
|
||||
url = bundle_meta.get("url") or identifier
|
||||
c.print(
|
||||
f"[bold red]Cannot install from URL:[/] {url}\n"
|
||||
"[yellow]The SKILL.md has no `name:` in its frontmatter, "
|
||||
"and the URL path doesn't produce a valid identifier.[/]\n\n"
|
||||
"Retry with an explicit name:\n"
|
||||
f" [bold]/skills install {url} --name <your-name>[/]\n"
|
||||
f" [bold]hermes skills install {url} --name <your-name>[/]\n\n"
|
||||
"[dim]Or ask the SKILL.md's author to add a `name:` field to "
|
||||
"its YAML frontmatter.[/]\n"
|
||||
)
|
||||
return
|
||||
else:
|
||||
# Interactive TTY — prompt.
|
||||
url = bundle_meta.get("url") or identifier
|
||||
chosen = _prompt_for_skill_name(c, url)
|
||||
if not chosen:
|
||||
c.print("[dim]Installation cancelled.[/]\n")
|
||||
return
|
||||
bundle.name = chosen
|
||||
bundle_meta["awaiting_name"] = False
|
||||
# Keep SkillMeta in sync so downstream "already installed" checks,
|
||||
# audit logs, and display all see the final name.
|
||||
if meta is not None:
|
||||
meta.name = bundle.name
|
||||
meta.path = bundle.name
|
||||
|
||||
# URL-sourced skills: offer to pick a category interactively when the
|
||||
# caller didn't specify one (TTY only — non-interactive installs fall
|
||||
# through to flat install, matching all other sources).
|
||||
if bundle.source == "url" and not category and not skip_confirm:
|
||||
category = _prompt_for_category(c, _existing_categories())
|
||||
|
||||
# Auto-detect category for official skills (e.g. "official/autonomous-ai-agents/blackbox")
|
||||
if bundle.source == "official" and not category:
|
||||
id_parts = bundle.identifier.split("/") # ["official", "category", "skill"]
|
||||
@@ -599,11 +758,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 +783,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 +813,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:
|
||||
@@ -1123,11 +1323,15 @@ def skills_command(args) -> None:
|
||||
do_search(args.query, source=args.source, limit=args.limit)
|
||||
elif action == "install":
|
||||
do_install(args.identifier, category=args.category, force=args.force,
|
||||
skip_confirm=getattr(args, "yes", False))
|
||||
skip_confirm=getattr(args, "yes", False),
|
||||
name_override=getattr(args, "name", "") or "")
|
||||
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":
|
||||
@@ -1177,6 +1381,7 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
||||
/skills search kubernetes
|
||||
/skills install openai/skills/skill-creator
|
||||
/skills install openai/skills/skill-creator --force
|
||||
/skills install https://example.com/path/SKILL.md
|
||||
/skills inspect openai/skills/skill-creator
|
||||
/skills list
|
||||
/skills list --source hub
|
||||
@@ -1253,10 +1458,11 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
||||
|
||||
elif action == "install":
|
||||
if not args:
|
||||
c.print("[bold red]Usage:[/] /skills install <identifier> [--category <cat>] [--force] [--now]\n")
|
||||
c.print("[bold red]Usage:[/] /skills install <identifier-or-url> [--name <name>] [--category <cat>] [--force] [--now]\n")
|
||||
return
|
||||
identifier = args[0]
|
||||
category = ""
|
||||
name_override = ""
|
||||
# Slash commands run inside prompt_toolkit where input() hangs.
|
||||
# Always skip confirmation — the user typing the command is implicit consent.
|
||||
skip_confirm = True
|
||||
@@ -1267,9 +1473,11 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
||||
for i, a in enumerate(args):
|
||||
if a == "--category" and i + 1 < len(args):
|
||||
category = args[i + 1]
|
||||
elif a == "--name" and i + 1 < len(args):
|
||||
name_override = args[i + 1]
|
||||
do_install(identifier, category=category, force=force,
|
||||
skip_confirm=skip_confirm, invalidate_cache=invalidate_cache,
|
||||
console=c)
|
||||
name_override=name_override, console=c)
|
||||
|
||||
elif action == "inspect":
|
||||
if not args:
|
||||
@@ -1279,11 +1487,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 +1580,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
|
||||
@@ -326,7 +326,8 @@ def show_status(args):
|
||||
"WeCom Callback": ("WECOM_CALLBACK_CORP_ID", None),
|
||||
"Weixin": ("WEIXIN_ACCOUNT_ID", "WEIXIN_HOME_CHANNEL"),
|
||||
"BlueBubbles": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_HOME_CHANNEL"),
|
||||
"QQBot": ("QQ_APP_ID", "QQBOT_HOME_CHANNEL"),
|
||||
"QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"),
|
||||
"Yuanbao": ("YUANBAO_APP_ID", "YUANBAO_HOME_CHANNEL"),
|
||||
}
|
||||
|
||||
for name, (token_var, home_var) in platforms.items():
|
||||
|
||||
@@ -20,10 +20,10 @@ def get_provider_request_timeout(
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
except ImportError:
|
||||
config = load_config()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
config = load_config()
|
||||
providers = config.get("providers", {}) if isinstance(config, dict) else {}
|
||||
provider_config = (
|
||||
providers.get(provider_id, {}) if isinstance(providers, dict) else {}
|
||||
@@ -49,10 +49,10 @@ def get_provider_stale_timeout(
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
except ImportError:
|
||||
config = load_config()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
config = load_config()
|
||||
providers = config.get("providers", {}) if isinstance(config, dict) else {}
|
||||
provider_config = (
|
||||
providers.get(provider_id, {}) if isinstance(providers, dict) else {}
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ TIPS = [
|
||||
"Set display.streaming: true to see tokens appear in real time as the model generates.",
|
||||
"Set display.show_reasoning: true to watch the model's chain-of-thought reasoning.",
|
||||
"Set display.compact: true to reduce whitespace in output for denser information.",
|
||||
"Set display.busy_input_mode: queue to queue messages instead of interrupting the agent.",
|
||||
"Set display.busy_input_mode: queue to queue messages instead of interrupting the agent, or steer to inject them mid-run via /steer.",
|
||||
"Set display.resume_display: minimal to skip the full conversation recap on session resume.",
|
||||
"Set compression.threshold: 0.50 to control when auto-compression fires (default: 50% of context).",
|
||||
"Set agent.max_turns: 200 to let the agent take more tool-calling steps per turn.",
|
||||
|
||||
@@ -11,6 +11,7 @@ the `platform_toolsets` key.
|
||||
|
||||
import json as _json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
@@ -25,7 +26,7 @@ from hermes_cli.nous_subscription import (
|
||||
get_nous_subscription_features,
|
||||
)
|
||||
from tools.tool_backend_helpers import fal_key_is_configured, managed_nous_tools_enabled
|
||||
from utils import base_url_hostname
|
||||
from utils import base_url_hostname, is_truthy_value
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -70,6 +71,7 @@ CONFIGURABLE_TOOLSETS = [
|
||||
("spotify", "🎵 Spotify", "playback, search, playlists, library"),
|
||||
("discord", "💬 Discord (read/participate)", "fetch messages, search members, create thread"),
|
||||
("discord_admin", "🛡️ Discord Server Admin", "list channels/roles, pin, assign roles"),
|
||||
("yuanbao", "🤖 Yuanbao", "group info, member queries, DM"),
|
||||
]
|
||||
|
||||
# Toolsets that are OFF by default for new installs.
|
||||
@@ -676,6 +678,15 @@ def _get_platform_tools(
|
||||
# their own platform (e.g. `discord` + `discord` should stay OFF).
|
||||
if platform in default_off and platform not in _TOOLSET_PLATFORM_RESTRICTIONS:
|
||||
default_off.remove(platform)
|
||||
# Home Assistant is already runtime-gated by its check_fn (requires
|
||||
# HASS_TOKEN to register any tools). When a user has configured
|
||||
# HASS_TOKEN, they've explicitly opted in — don't also strip it via
|
||||
# _DEFAULT_OFF_TOOLSETS, which would silently drop HA from platforms
|
||||
# (e.g. cron) that run through _get_platform_tools without an
|
||||
# explicit saved toolset list. Without this, Norbert's HA cron jobs
|
||||
# regressed after #14798 made cron honor per-platform tool config.
|
||||
if "homeassistant" in default_off and os.getenv("HASS_TOKEN"):
|
||||
default_off.remove("homeassistant")
|
||||
enabled_toolsets -= default_off
|
||||
|
||||
# Recover non-configurable platform toolsets (e.g. discord, feishu_doc,
|
||||
@@ -1177,7 +1188,7 @@ def _is_provider_active(provider: dict, config: dict) -> bool:
|
||||
configured_provider = image_cfg.get("provider")
|
||||
if configured_provider not in (None, "", "fal"):
|
||||
return False
|
||||
if image_cfg.get("use_gateway") is False:
|
||||
if image_cfg.get("use_gateway") is not None and not is_truthy_value(image_cfg.get("use_gateway"), default=False):
|
||||
return False
|
||||
return feature.managed_by_nous
|
||||
if provider.get("tts_provider"):
|
||||
@@ -1209,7 +1220,7 @@ def _is_provider_active(provider: dict, config: dict) -> bool:
|
||||
return (
|
||||
provider["imagegen_backend"] == "fal"
|
||||
and configured_provider in (None, "", "fal")
|
||||
and not image_cfg.get("use_gateway")
|
||||
and not is_truthy_value(image_cfg.get("use_gateway"), default=False)
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
||||
"display.busy_input_mode": {
|
||||
"type": "select",
|
||||
"description": "Input behavior while agent is running",
|
||||
"options": ["interrupt", "queue"],
|
||||
"options": ["interrupt", "queue", "steer"],
|
||||
},
|
||||
"memory.provider": {
|
||||
"type": "select",
|
||||
@@ -2327,16 +2327,14 @@ def _resolve_chat_argv(
|
||||
from hermes_cli.main import PROJECT_ROOT, _make_tui_argv
|
||||
|
||||
argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False)
|
||||
env: Optional[dict] = None
|
||||
env = os.environ.copy()
|
||||
env.setdefault("NODE_ENV", "production")
|
||||
|
||||
if resume or sidecar_url:
|
||||
env = os.environ.copy()
|
||||
if resume:
|
||||
env["HERMES_TUI_RESUME"] = resume
|
||||
|
||||
if resume:
|
||||
env["HERMES_TUI_RESUME"] = resume
|
||||
|
||||
if sidecar_url:
|
||||
env["HERMES_TUI_SIDECAR_URL"] = sidecar_url
|
||||
if sidecar_url:
|
||||
env["HERMES_TUI_SIDECAR_URL"] = sidecar_url
|
||||
|
||||
return list(argv), str(cwd) if cwd else None, env
|
||||
|
||||
|
||||
+3
-4
@@ -195,10 +195,6 @@ def setup_logging(
|
||||
The ``logs/`` directory where files are written.
|
||||
"""
|
||||
global _logging_initialized
|
||||
if _logging_initialized and not force:
|
||||
home = hermes_home or get_hermes_home()
|
||||
return home / "logs"
|
||||
|
||||
home = hermes_home or get_hermes_home()
|
||||
log_dir = home / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -248,6 +244,9 @@ def setup_logging(
|
||||
log_filter=_ComponentFilter(COMPONENT_PREFIXES["gateway"]),
|
||||
)
|
||||
|
||||
if _logging_initialized and not force:
|
||||
return log_dir
|
||||
|
||||
# Ensure root logger level is low enough for the handlers to fire.
|
||||
if root.level == logging.NOTSET or root.level > level:
|
||||
root.setLevel(level)
|
||||
|
||||
+129
-14
@@ -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 = ?")
|
||||
@@ -1121,20 +1132,27 @@ class SessionDB:
|
||||
current = child_id
|
||||
return session_id
|
||||
|
||||
def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:
|
||||
def get_messages_as_conversation(
|
||||
self, session_id: str, include_ancestors: bool = False
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Load messages in the OpenAI conversation format (role + content dicts).
|
||||
Used by the gateway to restore conversation history.
|
||||
"""
|
||||
session_ids = [session_id]
|
||||
if include_ancestors:
|
||||
session_ids = self._session_lineage_root_to_tip(session_id)
|
||||
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(
|
||||
placeholders = ",".join("?" for _ in session_ids)
|
||||
rows = self._conn.execute(
|
||||
"SELECT role, content, tool_call_id, tool_calls, tool_name, "
|
||||
"reasoning, reasoning_content, reasoning_details, codex_reasoning_items, "
|
||||
"codex_message_items "
|
||||
"FROM messages WHERE session_id = ? ORDER BY timestamp, id",
|
||||
(session_id,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
f"FROM messages WHERE session_id IN ({placeholders}) ORDER BY timestamp, id",
|
||||
tuple(session_ids),
|
||||
).fetchall()
|
||||
|
||||
messages = []
|
||||
for row in rows:
|
||||
msg = {"role": row["role"], "content": row["content"]}
|
||||
@@ -1174,9 +1192,47 @@ class SessionDB:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("Failed to deserialize codex_message_items, falling back to None")
|
||||
msg["codex_message_items"] = None
|
||||
if include_ancestors and self._is_duplicate_replayed_user_message(messages, msg):
|
||||
continue
|
||||
messages.append(msg)
|
||||
return messages
|
||||
|
||||
def _session_lineage_root_to_tip(self, session_id: str) -> List[str]:
|
||||
if not session_id:
|
||||
return [session_id]
|
||||
|
||||
chain = []
|
||||
current = session_id
|
||||
seen = set()
|
||||
with self._lock:
|
||||
for _ in range(100):
|
||||
if not current or current in seen:
|
||||
break
|
||||
seen.add(current)
|
||||
chain.append(current)
|
||||
row = self._conn.execute(
|
||||
"SELECT parent_session_id FROM sessions WHERE id = ?",
|
||||
(current,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
break
|
||||
current = row["parent_session_id"] if hasattr(row, "keys") else row[0]
|
||||
return list(reversed(chain)) or [session_id]
|
||||
|
||||
@staticmethod
|
||||
def _is_duplicate_replayed_user_message(messages: List[Dict[str, Any]], msg: Dict[str, Any]) -> bool:
|
||||
if msg.get("role") != "user":
|
||||
return False
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, str) or not content:
|
||||
return False
|
||||
for prev in reversed(messages):
|
||||
if prev.get("role") == "user" and prev.get("content") == content:
|
||||
return True
|
||||
if prev.get("role") == "assistant" and (prev.get("content") or prev.get("tool_calls")):
|
||||
return False
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# Search
|
||||
# =========================================================================
|
||||
@@ -1501,12 +1557,45 @@ class SessionDB:
|
||||
)
|
||||
self._execute_write(_do)
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
@staticmethod
|
||||
def _remove_session_files(sessions_dir: Optional[Path], session_id: str) -> None:
|
||||
"""Remove on-disk transcript files for a session.
|
||||
|
||||
Cleans up ``{session_id}.json``, ``{session_id}.jsonl``, and any
|
||||
``request_dump_{session_id}_*.json`` files left by the gateway.
|
||||
Silently skips files that don't exist and swallows OSError so a
|
||||
filesystem hiccup never blocks a DB operation.
|
||||
"""
|
||||
if sessions_dir is None:
|
||||
return
|
||||
for suffix in (".json", ".jsonl"):
|
||||
p = sessions_dir / f"{session_id}{suffix}"
|
||||
try:
|
||||
p.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
# request_dump files use session_id as a prefix component
|
||||
try:
|
||||
for p in sessions_dir.glob(f"request_dump_{session_id}_*.json"):
|
||||
try:
|
||||
p.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def delete_session(
|
||||
self,
|
||||
session_id: str,
|
||||
sessions_dir: Optional[Path] = None,
|
||||
) -> bool:
|
||||
"""Delete a session and all its messages.
|
||||
|
||||
Child sessions are orphaned (parent_session_id set to NULL) rather
|
||||
than cascade-deleted, so they remain accessible independently.
|
||||
Returns True if the session was found and deleted.
|
||||
When *sessions_dir* is provided, also removes on-disk transcript
|
||||
files (``.json`` / ``.jsonl`` / ``request_dump_*``) for the deleted
|
||||
session. Returns True if the session was found and deleted.
|
||||
"""
|
||||
def _do(conn):
|
||||
cursor = conn.execute(
|
||||
@@ -1523,16 +1612,29 @@ class SessionDB:
|
||||
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
|
||||
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
||||
return True
|
||||
return self._execute_write(_do)
|
||||
|
||||
def prune_sessions(self, older_than_days: int = 90, source: str = None) -> int:
|
||||
deleted = self._execute_write(_do)
|
||||
if deleted:
|
||||
self._remove_session_files(sessions_dir, session_id)
|
||||
return deleted
|
||||
|
||||
def prune_sessions(
|
||||
self,
|
||||
older_than_days: int = 90,
|
||||
source: str = None,
|
||||
sessions_dir: Optional[Path] = None,
|
||||
) -> int:
|
||||
"""Delete sessions older than N days. Returns count of deleted sessions.
|
||||
|
||||
Only prunes ended sessions (not active ones). Child sessions outside
|
||||
the prune window are orphaned (parent_session_id set to NULL) rather
|
||||
than cascade-deleted.
|
||||
than cascade-deleted. When *sessions_dir* is provided, also removes
|
||||
on-disk transcript files (``.json`` / ``.jsonl`` /
|
||||
``request_dump_*``) for every pruned session, outside the DB
|
||||
transaction.
|
||||
"""
|
||||
cutoff = time.time() - (older_than_days * 86400)
|
||||
removed_ids: list[str] = []
|
||||
|
||||
def _do(conn):
|
||||
if source:
|
||||
@@ -1562,9 +1664,14 @@ class SessionDB:
|
||||
for sid in session_ids:
|
||||
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
|
||||
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
|
||||
removed_ids.append(sid)
|
||||
return len(session_ids)
|
||||
|
||||
return self._execute_write(_do)
|
||||
count = self._execute_write(_do)
|
||||
# Clean up on-disk files outside the DB transaction
|
||||
for sid in removed_ids:
|
||||
self._remove_session_files(sessions_dir, sid)
|
||||
return count
|
||||
|
||||
# ── Meta key/value (for scheduler bookkeeping) ──
|
||||
|
||||
@@ -1618,6 +1725,7 @@ class SessionDB:
|
||||
retention_days: int = 90,
|
||||
min_interval_hours: int = 24,
|
||||
vacuum: bool = True,
|
||||
sessions_dir: Optional[Path] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Idempotent auto-maintenance: prune old sessions + optional VACUUM.
|
||||
|
||||
@@ -1625,6 +1733,10 @@ class SessionDB:
|
||||
within ``min_interval_hours`` no-op. Designed to be called once at
|
||||
startup from long-lived entrypoints (CLI, gateway, cron scheduler).
|
||||
|
||||
When *sessions_dir* is provided, on-disk transcript files
|
||||
(``.json`` / ``.jsonl`` / ``request_dump_*``) for pruned sessions
|
||||
are removed as part of the same sweep (issue #3015).
|
||||
|
||||
Never raises. On any failure, logs a warning and returns a dict
|
||||
with ``"error"`` set.
|
||||
|
||||
@@ -1648,7 +1760,10 @@ class SessionDB:
|
||||
except (TypeError, ValueError):
|
||||
pass # corrupt meta; treat as no prior run
|
||||
|
||||
pruned = self.prune_sessions(older_than_days=retention_days)
|
||||
pruned = self.prune_sessions(
|
||||
older_than_days=retention_days,
|
||||
sessions_dir=sessions_dir,
|
||||
)
|
||||
result["pruned"] = pruned
|
||||
|
||||
# Only VACUUM if we actually freed rows — VACUUM on a tight DB
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ let
|
||||
src = ../ui-tui;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-RU4qSHgJPMyfRSEJDzkG4+MReDZDc6QbTD2wisa5QE0=";
|
||||
hash = "sha256-Chz+NW9NXqboXHOa6PKwf5bhAkkcFtKNhvKWwg2XSPc=";
|
||||
};
|
||||
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
|
||||
|
||||
@@ -380,6 +380,10 @@ def backup_existing(path: Path, backup_root: Path) -> Optional[Path]:
|
||||
# Replace OpenClaw brand names with Hermes in migrated text so that
|
||||
# memory entries, user profiles, SOUL.md, and workspace instructions
|
||||
# read as self-referential to the new agent identity.
|
||||
#
|
||||
# Case-preserving: ``OpenClaw`` → ``Hermes`` (prose), but lowercase matches
|
||||
# like ``openclaw`` → ``hermes`` (so filesystem paths like ``~/.openclaw``
|
||||
# become ``~/.hermes`` — the real Hermes home — not the broken ``~/.Hermes``).
|
||||
_REBRAND_PATTERNS: List[Tuple[re.Pattern, str]] = [
|
||||
(re.compile(r'\bOpen[\s-]?Claw\b', re.IGNORECASE), 'Hermes'),
|
||||
(re.compile(r'\bClawdBot\b', re.IGNORECASE), 'Hermes'),
|
||||
@@ -387,10 +391,31 @@ _REBRAND_PATTERNS: List[Tuple[re.Pattern, str]] = [
|
||||
]
|
||||
|
||||
|
||||
def _case_preserving_replacement(replacement: str):
|
||||
"""Return a re.sub replacement fn that lowercases the result when the
|
||||
matched text was all-lowercase.
|
||||
|
||||
Keeps ``OpenClaw`` → ``Hermes`` but maps ``openclaw`` → ``hermes`` so a
|
||||
filesystem path like ``~/.openclaw/config.yaml`` rewrites to
|
||||
``~/.hermes/config.yaml`` (the real Hermes home) instead of the broken
|
||||
``~/.Hermes/config.yaml``.
|
||||
"""
|
||||
def _sub(match: "re.Match[str]") -> str:
|
||||
matched = match.group(0)
|
||||
if matched and matched.islower():
|
||||
return replacement.lower()
|
||||
return replacement
|
||||
return _sub
|
||||
|
||||
|
||||
def rebrand_text(text: str) -> str:
|
||||
"""Replace OpenClaw / ClawdBot / MoltBot brand names with Hermes."""
|
||||
"""Replace OpenClaw / ClawdBot / MoltBot brand names with Hermes.
|
||||
|
||||
Preserves case so filesystem-path matches (lowercase) don't become
|
||||
capitalized directory names that don't exist.
|
||||
"""
|
||||
for pattern, replacement in _REBRAND_PATTERNS:
|
||||
text = pattern.sub(replacement, text)
|
||||
text = pattern.sub(_case_preserving_replacement(replacement), text)
|
||||
return text
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
Long-term memory with knowledge graph, entity resolution, and multi-strategy
|
||||
retrieval. Supports cloud (API key) and local modes.
|
||||
|
||||
Configurable timeout via HINDSIGHT_TIMEOUT env var or config.json.
|
||||
Configurable request timeout via HINDSIGHT_TIMEOUT env var or config.json.
|
||||
Configurable embedded daemon idle timeout via HINDSIGHT_IDLE_TIMEOUT env var
|
||||
or config.json idle_timeout.
|
||||
|
||||
Original PR #1811 by benfrank241, adapted to MemoryProvider ABC.
|
||||
|
||||
@@ -14,6 +16,7 @@ Config via environment variables:
|
||||
HINDSIGHT_API_URL — API endpoint
|
||||
HINDSIGHT_MODE — cloud or local (default: cloud)
|
||||
HINDSIGHT_TIMEOUT — API request timeout in seconds (default: 120)
|
||||
HINDSIGHT_IDLE_TIMEOUT — embedded daemon idle timeout seconds; 0 disables shutdown (default: 300)
|
||||
HINDSIGHT_RETAIN_TAGS — comma-separated tags attached to retained memories
|
||||
HINDSIGHT_RETAIN_SOURCE — metadata source value attached to retained memories
|
||||
HINDSIGHT_RETAIN_USER_PREFIX — label used before user turns in retained transcripts
|
||||
@@ -45,6 +48,7 @@ _DEFAULT_API_URL = "https://api.hindsight.vectorize.io"
|
||||
_DEFAULT_LOCAL_URL = "http://localhost:8888"
|
||||
_MIN_CLIENT_VERSION = "0.4.22"
|
||||
_DEFAULT_TIMEOUT = 120 # seconds — cloud API can take 30-40s per request
|
||||
_DEFAULT_IDLE_TIMEOUT = 300 # seconds — Hindsight embedded daemon default
|
||||
_VALID_BUDGETS = {"low", "mid", "high"}
|
||||
_PROVIDER_DEFAULT_MODELS = {
|
||||
"openai": "gpt-4o-mini",
|
||||
@@ -59,6 +63,17 @@ _PROVIDER_DEFAULT_MODELS = {
|
||||
}
|
||||
|
||||
|
||||
def _parse_int_setting(value: Any, default: int) -> int:
|
||||
"""Parse an integer config/env value, falling back on invalid input."""
|
||||
if value is None or value == "":
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Invalid integer Hindsight setting %r; using default %s", value, default)
|
||||
return default
|
||||
|
||||
|
||||
def _check_local_runtime() -> tuple[bool, str | None]:
|
||||
"""Return whether local embedded Hindsight imports cleanly.
|
||||
|
||||
@@ -203,6 +218,8 @@ def _load_config() -> dict:
|
||||
return {
|
||||
"mode": os.environ.get("HINDSIGHT_MODE", "cloud"),
|
||||
"apiKey": os.environ.get("HINDSIGHT_API_KEY", ""),
|
||||
"timeout": _parse_int_setting(os.environ.get("HINDSIGHT_TIMEOUT"), _DEFAULT_TIMEOUT),
|
||||
"idle_timeout": _parse_int_setting(os.environ.get("HINDSIGHT_IDLE_TIMEOUT"), _DEFAULT_IDLE_TIMEOUT),
|
||||
"retain_tags": os.environ.get("HINDSIGHT_RETAIN_TAGS", ""),
|
||||
"retain_source": os.environ.get("HINDSIGHT_RETAIN_SOURCE", ""),
|
||||
"retain_user_prefix": os.environ.get("HINDSIGHT_RETAIN_USER_PREFIX", "User"),
|
||||
@@ -304,6 +321,16 @@ def _build_embedded_profile_env(config: dict[str, Any], *, llm_api_key: str | No
|
||||
}
|
||||
if current_base_url:
|
||||
env_values["HINDSIGHT_API_LLM_BASE_URL"] = str(current_base_url)
|
||||
|
||||
idle_timeout = (
|
||||
config.get("idle_timeout")
|
||||
if config.get("idle_timeout") is not None
|
||||
else os.environ.get("HINDSIGHT_IDLE_TIMEOUT")
|
||||
)
|
||||
if idle_timeout is not None and idle_timeout != "":
|
||||
env_values["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] = str(
|
||||
_parse_int_setting(idle_timeout, _DEFAULT_IDLE_TIMEOUT)
|
||||
)
|
||||
return env_values
|
||||
|
||||
|
||||
@@ -412,6 +439,7 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
self._turn_index = 0
|
||||
self._client = None
|
||||
self._timeout = _DEFAULT_TIMEOUT
|
||||
self._idle_timeout = _DEFAULT_IDLE_TIMEOUT
|
||||
self._prefetch_result = ""
|
||||
self._prefetch_lock = threading.Lock()
|
||||
self._prefetch_thread = None
|
||||
@@ -592,10 +620,17 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
sys.stdout.write(" LLM API key: ")
|
||||
sys.stdout.flush()
|
||||
llm_key = getpass.getpass(prompt="") if sys.stdin.isatty() else sys.stdin.readline().strip()
|
||||
# Always write explicitly (including empty) so the provider sees ""
|
||||
# rather than a missing variable. The daemon reads from .env at
|
||||
# startup and fails when HINDSIGHT_LLM_API_KEY is unset.
|
||||
env_writes["HINDSIGHT_LLM_API_KEY"] = llm_key
|
||||
if llm_key:
|
||||
env_writes["HINDSIGHT_LLM_API_KEY"] = llm_key
|
||||
else:
|
||||
env_path = Path(hermes_home) / ".env"
|
||||
existing_llm_key = ""
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text().splitlines():
|
||||
if line.startswith("HINDSIGHT_LLM_API_KEY="):
|
||||
existing_llm_key = line.split("=", 1)[1]
|
||||
break
|
||||
env_writes["HINDSIGHT_LLM_API_KEY"] = existing_llm_key
|
||||
|
||||
# Step 4: Save everything
|
||||
provider_config["bank_id"] = "hermes"
|
||||
@@ -605,6 +640,11 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
timeout_val = existing_timeout if existing_timeout else _DEFAULT_TIMEOUT
|
||||
provider_config["timeout"] = timeout_val
|
||||
env_writes["HINDSIGHT_TIMEOUT"] = str(timeout_val)
|
||||
if mode == "local_embedded":
|
||||
existing_idle_timeout = self._config.get("idle_timeout") if self._config else None
|
||||
idle_timeout_val = existing_idle_timeout if existing_idle_timeout is not None else _DEFAULT_IDLE_TIMEOUT
|
||||
provider_config["idle_timeout"] = idle_timeout_val
|
||||
env_writes["HINDSIGHT_IDLE_TIMEOUT"] = str(idle_timeout_val)
|
||||
config["memory"]["provider"] = "hindsight"
|
||||
save_config(config)
|
||||
|
||||
@@ -693,6 +733,7 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
{"key": "recall_max_input_chars", "description": "Maximum input query length for auto-recall", "default": 800},
|
||||
{"key": "recall_prompt_preamble", "description": "Custom preamble for recalled memories in context"},
|
||||
{"key": "timeout", "description": "API request timeout in seconds", "default": _DEFAULT_TIMEOUT},
|
||||
{"key": "idle_timeout", "description": "Embedded daemon idle timeout in seconds (0 disables auto-shutdown)", "default": _DEFAULT_IDLE_TIMEOUT, "when": {"mode": "local_embedded"}},
|
||||
]
|
||||
|
||||
def _get_client(self):
|
||||
@@ -720,6 +761,14 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
)
|
||||
if self._llm_base_url:
|
||||
kwargs["llm_base_url"] = self._llm_base_url
|
||||
idle_timeout = _parse_int_setting(
|
||||
self._config.get("idle_timeout")
|
||||
if self._config.get("idle_timeout") is not None
|
||||
else os.environ.get("HINDSIGHT_IDLE_TIMEOUT", self._idle_timeout),
|
||||
_DEFAULT_IDLE_TIMEOUT,
|
||||
)
|
||||
self._idle_timeout = idle_timeout
|
||||
kwargs["idle_timeout"] = idle_timeout
|
||||
self._client = HindsightEmbedded(**kwargs)
|
||||
else:
|
||||
from hindsight_client import Hindsight
|
||||
@@ -736,6 +785,38 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
"""Schedule *coro* on the shared loop using the configured timeout."""
|
||||
return _run_sync(coro, timeout=self._timeout)
|
||||
|
||||
def _is_retriable_embedded_connection_error(self, exc: Exception) -> bool:
|
||||
"""Return True for stale embedded-daemon connection failures."""
|
||||
if self._mode != "local_embedded":
|
||||
return False
|
||||
text = f"{type(exc).__name__}: {exc}".lower()
|
||||
return any(
|
||||
marker in text
|
||||
for marker in (
|
||||
"cannot connect to host",
|
||||
"connection refused",
|
||||
"connect call failed",
|
||||
"clientconnectorerror",
|
||||
)
|
||||
)
|
||||
|
||||
def _run_hindsight_operation(self, operation):
|
||||
"""Run an async Hindsight client operation, retrying once after idle shutdown."""
|
||||
client = self._get_client()
|
||||
try:
|
||||
return self._run_sync(operation(client))
|
||||
except Exception as exc:
|
||||
if not self._is_retriable_embedded_connection_error(exc):
|
||||
raise
|
||||
logger.info(
|
||||
"Hindsight embedded daemon appears unreachable; recreating client and retrying once: %s",
|
||||
exc,
|
||||
)
|
||||
self._client = None
|
||||
client = self._get_client()
|
||||
self._client = client
|
||||
return self._run_sync(operation(client))
|
||||
|
||||
def initialize(self, session_id: str, **kwargs) -> None:
|
||||
self._session_id = str(session_id or "").strip()
|
||||
self._parent_session_id = str(kwargs.get("parent_session_id", "") or "").strip()
|
||||
@@ -790,7 +871,14 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
self._session_turns = []
|
||||
self._mode = self._config.get("mode", "cloud")
|
||||
# Read timeout from config or env var, fall back to default
|
||||
self._timeout = self._config.get("timeout") or int(os.environ.get("HINDSIGHT_TIMEOUT", str(_DEFAULT_TIMEOUT)))
|
||||
self._timeout = _parse_int_setting(
|
||||
self._config.get("timeout") if self._config.get("timeout") is not None else os.environ.get("HINDSIGHT_TIMEOUT"),
|
||||
_DEFAULT_TIMEOUT,
|
||||
)
|
||||
self._idle_timeout = _parse_int_setting(
|
||||
self._config.get("idle_timeout") if self._config.get("idle_timeout") is not None else os.environ.get("HINDSIGHT_IDLE_TIMEOUT"),
|
||||
_DEFAULT_IDLE_TIMEOUT,
|
||||
)
|
||||
# "local" is a legacy alias for "local_embedded"
|
||||
if self._mode == "local":
|
||||
self._mode = "local_embedded"
|
||||
@@ -981,10 +1069,9 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
|
||||
def _run():
|
||||
try:
|
||||
client = self._get_client()
|
||||
if self._prefetch_method == "reflect":
|
||||
logger.debug("Prefetch: calling reflect (bank=%s, query_len=%d)", self._bank_id, len(query))
|
||||
resp = self._run_sync(client.areflect(bank_id=self._bank_id, query=query, budget=self._budget))
|
||||
resp = self._run_hindsight_operation(lambda client: client.areflect(bank_id=self._bank_id, query=query, budget=self._budget))
|
||||
text = resp.text or ""
|
||||
else:
|
||||
recall_kwargs: dict = {
|
||||
@@ -998,7 +1085,7 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
recall_kwargs["types"] = self._recall_types
|
||||
logger.debug("Prefetch: calling recall (bank=%s, query_len=%d, budget=%s)",
|
||||
self._bank_id, len(query), self._budget)
|
||||
resp = self._run_sync(client.arecall(**recall_kwargs))
|
||||
resp = self._run_hindsight_operation(lambda client: client.arecall(**recall_kwargs))
|
||||
num_results = len(resp.results) if resp.results else 0
|
||||
logger.debug("Prefetch: recall returned %d results", num_results)
|
||||
text = "\n".join(f"- {r.text}" for r in resp.results if r.text) if resp.results else ""
|
||||
@@ -1131,12 +1218,14 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
item.pop("retain_async", None)
|
||||
logger.debug("Hindsight retain: bank=%s, doc=%s, async=%s, content_len=%d, num_turns=%d",
|
||||
self._bank_id, self._document_id, self._retain_async, len(content), len(self._session_turns))
|
||||
self._run_sync(client.aretain_batch(
|
||||
bank_id=self._bank_id,
|
||||
items=[item],
|
||||
document_id=self._document_id,
|
||||
retain_async=self._retain_async,
|
||||
))
|
||||
self._run_hindsight_operation(
|
||||
lambda client: client.aretain_batch(
|
||||
bank_id=self._bank_id,
|
||||
items=[item],
|
||||
document_id=self._document_id,
|
||||
retain_async=self._retain_async,
|
||||
)
|
||||
)
|
||||
logger.debug("Hindsight retain succeeded")
|
||||
except Exception as e:
|
||||
logger.warning("Hindsight sync failed: %s", e, exc_info=True)
|
||||
@@ -1152,12 +1241,6 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
return [RETAIN_SCHEMA, RECALL_SCHEMA, REFLECT_SCHEMA]
|
||||
|
||||
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
|
||||
try:
|
||||
client = self._get_client()
|
||||
except Exception as e:
|
||||
logger.warning("Hindsight client init failed: %s", e)
|
||||
return tool_error(f"Hindsight client unavailable: {e}")
|
||||
|
||||
if tool_name == "hindsight_retain":
|
||||
content = args.get("content", "")
|
||||
if not content:
|
||||
@@ -1171,7 +1254,7 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
)
|
||||
logger.debug("Tool hindsight_retain: bank=%s, content_len=%d, context=%s",
|
||||
self._bank_id, len(content), context)
|
||||
self._run_sync(client.aretain(**retain_kwargs))
|
||||
self._run_hindsight_operation(lambda client: client.aretain(**retain_kwargs))
|
||||
logger.debug("Tool hindsight_retain: success")
|
||||
return json.dumps({"result": "Memory stored successfully."})
|
||||
except Exception as e:
|
||||
@@ -1194,7 +1277,7 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
recall_kwargs["types"] = self._recall_types
|
||||
logger.debug("Tool hindsight_recall: bank=%s, query_len=%d, budget=%s",
|
||||
self._bank_id, len(query), self._budget)
|
||||
resp = self._run_sync(client.arecall(**recall_kwargs))
|
||||
resp = self._run_hindsight_operation(lambda client: client.arecall(**recall_kwargs))
|
||||
num_results = len(resp.results) if resp.results else 0
|
||||
logger.debug("Tool hindsight_recall: %d results", num_results)
|
||||
if not resp.results:
|
||||
@@ -1212,9 +1295,11 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
try:
|
||||
logger.debug("Tool hindsight_reflect: bank=%s, query_len=%d, budget=%s",
|
||||
self._bank_id, len(query), self._budget)
|
||||
resp = self._run_sync(client.areflect(
|
||||
bank_id=self._bank_id, query=query, budget=self._budget
|
||||
))
|
||||
resp = self._run_hindsight_operation(
|
||||
lambda client: client.areflect(
|
||||
bank_id=self._bank_id, query=query, budget=self._budget
|
||||
)
|
||||
)
|
||||
logger.debug("Tool hindsight_reflect: response_len=%d", len(resp.text or ""))
|
||||
return json.dumps({"result": resp.text or "No relevant memories found."})
|
||||
except Exception as e:
|
||||
@@ -1231,9 +1316,19 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
if self._client is not None:
|
||||
try:
|
||||
if self._mode == "local_embedded":
|
||||
# Use the public close() API. The RuntimeError from
|
||||
# aiohttp's "attached to a different loop" is expected
|
||||
# and harmless — the daemon keeps running independently.
|
||||
# HindsightEmbedded.close() delegates to its sync client.close().
|
||||
# When Hermes created/used that client on the shared async loop,
|
||||
# closing it from this thread can raise "attached to a different
|
||||
# loop" before aiohttp releases the session. Close the embedded
|
||||
# inner async client on the shared loop first, then let the
|
||||
# wrapper clean up daemon/UI bookkeeping.
|
||||
inner_client = getattr(self._client, "_client", None)
|
||||
if inner_client is not None and hasattr(inner_client, "aclose"):
|
||||
_run_sync(inner_client.aclose())
|
||||
try:
|
||||
self._client._client = None
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._client.close()
|
||||
except RuntimeError:
|
||||
|
||||
+28
-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:
|
||||
@@ -8151,6 +8160,22 @@ class AIAgent:
|
||||
except Exception as e:
|
||||
logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e)
|
||||
|
||||
# Notify the context engine that the session_id rotated because of
|
||||
# compression (not a fresh /new). Plugin engines (e.g. hermes-lcm) use
|
||||
# boundary_reason="compression" to preserve DAG lineage across the
|
||||
# rollover instead of re-initializing fresh per-session state.
|
||||
# See hermes-lcm#68. Built-in ContextCompressor ignores kwargs.
|
||||
try:
|
||||
_old_sid = locals().get("old_session_id")
|
||||
if _old_sid and hasattr(self.context_compressor, "on_session_start"):
|
||||
self.context_compressor.on_session_start(
|
||||
self.session_id or "",
|
||||
boundary_reason="compression",
|
||||
old_session_id=_old_sid,
|
||||
)
|
||||
except Exception as _ce_err:
|
||||
logger.debug("context engine on_session_start (compression): %s", _ce_err)
|
||||
|
||||
# Warn on repeated compressions (quality degrades with each pass)
|
||||
_cc = self.context_compressor.compression_count
|
||||
if _cc >= 2:
|
||||
|
||||
+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
|
||||
|
||||
Executable
+614
@@ -0,0 +1,614 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Drive the Hermes TUI under HERMES_DEV_PERF and summarize the pipeline.
|
||||
|
||||
Usage:
|
||||
scripts/profile-tui.py [--session SID] [--hold KEY] [--seconds N] [--rate HZ]
|
||||
|
||||
Defaults: picks the session with the most messages, holds PageUp for 8s at
|
||||
~30 Hz (matching xterm key-repeat), summarizes ~/.hermes/perf.log on exit.
|
||||
|
||||
The --tui build must exist (run `npm run build` in ui-tui first). This script
|
||||
launches `node dist/entry.js` directly with HERMES_TUI_RESUME set so it
|
||||
bypasses the hermes_cli wrapper — we want repeatable timing, not the CLI's
|
||||
session-picker flow.
|
||||
|
||||
Environment overrides:
|
||||
HERMES_PERF_LOG (default ~/.hermes/perf.log)
|
||||
HERMES_PERF_NODE (default node from $PATH)
|
||||
HERMES_TUI_DIR (default /home/bb/hermes-agent/ui-tui)
|
||||
|
||||
Exit code is 0 if the harness ran and parsed results, 2 if the TUI crashed
|
||||
or produced no perf data (suggests HERMES_DEV_PERF wiring is broken).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_TUI_DIR = Path(os.environ.get("HERMES_TUI_DIR", "/home/bb/hermes-agent/ui-tui"))
|
||||
DEFAULT_LOG = Path(os.environ.get("HERMES_PERF_LOG", str(Path.home() / ".hermes" / "perf.log")))
|
||||
DEFAULT_STATE_DB = Path.home() / ".hermes" / "state.db"
|
||||
|
||||
# Keystroke escape sequences. Matches what xterm/VT220 send when the
|
||||
# terminal has bracketed-paste disabled and the key-repeat handler fires.
|
||||
KEYS = {
|
||||
"page_up": b"\x1b[5~",
|
||||
"page_down": b"\x1b[6~",
|
||||
"wheel_up": b"\x1b[M`!!", # mouse wheel up (SGR-less) — best-effort
|
||||
"shift_up": b"\x1b[1;2A",
|
||||
"shift_down": b"\x1b[1;2B",
|
||||
}
|
||||
|
||||
|
||||
def pick_longest_session(db: Path) -> str:
|
||||
conn = sqlite3.connect(db)
|
||||
row = conn.execute(
|
||||
"SELECT id FROM sessions s ORDER BY "
|
||||
"(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if not row:
|
||||
sys.exit(f"no sessions in {db}")
|
||||
return row[0]
|
||||
|
||||
|
||||
def drain(fd: int, timeout: float) -> bytes:
|
||||
"""Read whatever's available from fd within `timeout`, then return."""
|
||||
chunks = []
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
r, _, _ = select.select([fd], [], [], max(0.0, end - time.monotonic()))
|
||||
if not r:
|
||||
break
|
||||
try:
|
||||
data = os.read(fd, 4096)
|
||||
except OSError:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
chunks.append(data)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def hold_key(fd: int, seq: bytes, seconds: float, rate_hz: int) -> int:
|
||||
"""Write `seq` to fd at ~rate_hz for `seconds`. Returns keystrokes sent."""
|
||||
interval = 1.0 / max(1, rate_hz)
|
||||
end = time.monotonic() + seconds
|
||||
sent = 0
|
||||
while time.monotonic() < end:
|
||||
try:
|
||||
os.write(fd, seq)
|
||||
sent += 1
|
||||
except OSError:
|
||||
break
|
||||
# Drain stdout to keep the PTY buffer flowing; ignore content.
|
||||
drain(fd, 0)
|
||||
time.sleep(interval)
|
||||
return sent
|
||||
|
||||
|
||||
def summarize(log: Path, since_ts_ms: int) -> dict[str, Any]:
|
||||
"""Parse perf.log, keep only events newer than since_ts_ms, return stats."""
|
||||
react_events: list[dict[str, Any]] = []
|
||||
frame_events: list[dict[str, Any]] = []
|
||||
if not log.exists():
|
||||
return {"error": f"no log at {log}", "react": [], "frame": []}
|
||||
for line in log.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if int(row.get("ts", 0)) < since_ts_ms:
|
||||
continue
|
||||
src = row.get("src")
|
||||
if src == "react":
|
||||
react_events.append(row)
|
||||
elif src == "frame":
|
||||
frame_events.append(row)
|
||||
|
||||
return {
|
||||
"react": react_events,
|
||||
"frame": frame_events,
|
||||
}
|
||||
|
||||
|
||||
def pct(values: list[float], p: float) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
s = sorted(values)
|
||||
idx = min(len(s) - 1, int(len(s) * p))
|
||||
return s[idx]
|
||||
|
||||
|
||||
def format_report(data: dict[str, Any]) -> str:
|
||||
react = data.get("react") or []
|
||||
frames = data.get("frame") or []
|
||||
out = []
|
||||
|
||||
out.append("═══ React Profiler ═══")
|
||||
if not react:
|
||||
out.append(" (no react events — HERMES_DEV_PERF wired? threshold too high?)")
|
||||
else:
|
||||
by_id: dict[str, list[float]] = {}
|
||||
for r in react:
|
||||
by_id.setdefault(r["id"], []).append(r["actualMs"])
|
||||
out.append(f" {'pane':<14} {'count':>6} {'p50':>8} {'p95':>8} {'p99':>8} {'max':>8}")
|
||||
for pid, ms in sorted(by_id.items(), key=lambda kv: -pct(kv[1], 0.99)):
|
||||
out.append(
|
||||
f" {pid:<14} {len(ms):>6} {pct(ms,0.50):>8.2f} {pct(ms,0.95):>8.2f} "
|
||||
f"{pct(ms,0.99):>8.2f} {max(ms):>8.2f}"
|
||||
)
|
||||
|
||||
out.append("")
|
||||
out.append("═══ Ink pipeline ═══")
|
||||
if not frames:
|
||||
out.append(" (no frame events — onFrame wiring broken?)")
|
||||
else:
|
||||
dur = [f["durationMs"] for f in frames]
|
||||
phases_present = any(f.get("phases") for f in frames)
|
||||
out.append(f" frames captured: {len(frames)}")
|
||||
out.append(
|
||||
f" durationMs p50={pct(dur,0.50):.2f} p95={pct(dur,0.95):.2f} "
|
||||
f"p99={pct(dur,0.99):.2f} max={max(dur):.2f}"
|
||||
)
|
||||
# Effective FPS during the run: frames / elapsed seconds.
|
||||
ts = sorted(f["ts"] for f in frames)
|
||||
if len(ts) >= 2:
|
||||
elapsed_s = (ts[-1] - ts[0]) / 1000.0
|
||||
fps = len(frames) / elapsed_s if elapsed_s > 0 else float("inf")
|
||||
out.append(f" throughput: {len(frames)} frames / {elapsed_s:.2f}s = {fps:.1f} fps")
|
||||
|
||||
if phases_present:
|
||||
fields = ["yoga", "renderer", "diff", "optimize", "write", "commit"]
|
||||
out.append("")
|
||||
out.append(f" {'phase':<10} {'p50':>8} {'p95':>8} {'p99':>8} {'max':>8} (ms)")
|
||||
for field in fields:
|
||||
vals = [f["phases"][field] for f in frames if f.get("phases")]
|
||||
if vals:
|
||||
out.append(
|
||||
f" {field:<10} {pct(vals,0.50):>8.2f} {pct(vals,0.95):>8.2f} "
|
||||
f"{pct(vals,0.99):>8.2f} {max(vals):>8.2f}"
|
||||
)
|
||||
# Derived: sum of phases vs durationMs (reveals hidden time).
|
||||
sum_ps = [
|
||||
sum(f["phases"][k] for k in fields)
|
||||
for f in frames if f.get("phases")
|
||||
]
|
||||
if sum_ps:
|
||||
dur_match = [f["durationMs"] for f in frames if f.get("phases")]
|
||||
deltas = [d - s for d, s in zip(dur_match, sum_ps)]
|
||||
out.append(
|
||||
f" {'dur-Σphases':<10} {pct(deltas,0.50):>8.2f} {pct(deltas,0.95):>8.2f} "
|
||||
f"{pct(deltas,0.99):>8.2f} {max(deltas):>8.2f} (unaccounted-for time)"
|
||||
)
|
||||
|
||||
# Yoga counters
|
||||
visited = [f["phases"]["yogaVisited"] for f in frames if f.get("phases")]
|
||||
measured = [f["phases"]["yogaMeasured"] for f in frames if f.get("phases")]
|
||||
cache_hits = [f["phases"]["yogaCacheHits"] for f in frames if f.get("phases")]
|
||||
live = [f["phases"]["yogaLive"] for f in frames if f.get("phases")]
|
||||
out.append("")
|
||||
out.append(" Yoga counters (per frame):")
|
||||
for name, vals in (
|
||||
("visited", visited),
|
||||
("measured", measured),
|
||||
("cacheHits", cache_hits),
|
||||
("live", live),
|
||||
):
|
||||
if vals:
|
||||
out.append(f" {name:<11} p50={pct(vals,0.5):.0f} p99={pct(vals,0.99):.0f} max={max(vals)}")
|
||||
|
||||
# Patch counts — proxy for "how much changed each frame"
|
||||
patches = [f["phases"]["patches"] for f in frames if f.get("phases")]
|
||||
if patches:
|
||||
out.append(
|
||||
f" patches p50={pct(patches,0.5):.0f} p99={pct(patches,0.99):.0f} "
|
||||
f"max={max(patches)} total={sum(patches)}"
|
||||
)
|
||||
optimized = [
|
||||
f["phases"].get("optimizedPatches", 0)
|
||||
for f in frames if f.get("phases")
|
||||
]
|
||||
if any(optimized):
|
||||
out.append(
|
||||
f" optimized p50={pct(optimized,0.5):.0f} p99={pct(optimized,0.99):.0f} "
|
||||
f"max={max(optimized)} total={sum(optimized)}"
|
||||
f" (ratio: {sum(optimized)/max(1,sum(patches)):.2f})"
|
||||
)
|
||||
|
||||
# Write bytes + drain telemetry — the outer-terminal bottleneck gauge.
|
||||
bytes_written = [
|
||||
f["phases"].get("writeBytes", 0)
|
||||
for f in frames if f.get("phases")
|
||||
]
|
||||
if any(bytes_written):
|
||||
total_b = sum(bytes_written)
|
||||
kb = total_b / 1024
|
||||
out.append(
|
||||
f" writeBytes p50={pct(bytes_written,0.5):.0f}B p99={pct(bytes_written,0.99):.0f}B "
|
||||
f"max={max(bytes_written)}B total={kb:.1f}KB"
|
||||
)
|
||||
drains = [
|
||||
f["phases"].get("prevFrameDrainMs", 0)
|
||||
for f in frames if f.get("phases")
|
||||
]
|
||||
if any(d > 0 for d in drains):
|
||||
nonzero = [d for d in drains if d > 0]
|
||||
out.append(
|
||||
f" drainMs p50={pct(nonzero,0.5):.2f} p95={pct(nonzero,0.95):.2f} "
|
||||
f"p99={pct(nonzero,0.99):.2f} max={max(nonzero):.2f} (terminal flush latency)"
|
||||
)
|
||||
backpressure = sum(1 for f in frames if f.get("phases", {}).get("backpressure"))
|
||||
if backpressure:
|
||||
out.append(
|
||||
f" backpressure: {backpressure}/{len(frames)} frames "
|
||||
f"({100*backpressure/len(frames):.0f}%) (Node stdout buffer full — terminal slow)"
|
||||
)
|
||||
|
||||
# Flickers
|
||||
flicker_frames = [f for f in frames if f.get("flickers")]
|
||||
if flicker_frames:
|
||||
out.append("")
|
||||
out.append(f" ⚠ flickers detected in {len(flicker_frames)} frames")
|
||||
reasons: dict[str, int] = {}
|
||||
for f in flicker_frames:
|
||||
for fl in f["flickers"]:
|
||||
reasons[fl["reason"]] = reasons.get(fl["reason"], 0) + 1
|
||||
for reason, n in sorted(reasons.items(), key=lambda kv: -kv[1]):
|
||||
out.append(f" {reason}: {n}")
|
||||
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def key_metrics(data: dict[str, Any]) -> dict[str, float]:
|
||||
"""Flatten the report into a dict of scalar metrics for A/B diffing."""
|
||||
metrics: dict[str, float] = {}
|
||||
frames = data.get("frame") or []
|
||||
react = data.get("react") or []
|
||||
|
||||
if frames:
|
||||
durs = [f["durationMs"] for f in frames]
|
||||
metrics["frames"] = len(frames)
|
||||
metrics["dur_p50"] = pct(durs, 0.50)
|
||||
metrics["dur_p95"] = pct(durs, 0.95)
|
||||
metrics["dur_p99"] = pct(durs, 0.99)
|
||||
metrics["dur_max"] = max(durs)
|
||||
|
||||
ts = sorted(f["ts"] for f in frames)
|
||||
if len(ts) >= 2:
|
||||
elapsed = (ts[-1] - ts[0]) / 1000.0
|
||||
metrics["fps_throughput"] = len(frames) / elapsed if elapsed > 0 else 0.0
|
||||
# Interframe gaps distribution — complementary view to throughput:
|
||||
gaps = [ts[i] - ts[i - 1] for i in range(1, len(ts))]
|
||||
if gaps:
|
||||
metrics["gap_p50_ms"] = pct(gaps, 0.50)
|
||||
metrics["gap_p99_ms"] = pct(gaps, 0.99)
|
||||
metrics["gaps_under_16ms"] = sum(1 for g in gaps if g < 16)
|
||||
metrics["gaps_over_200ms"] = sum(1 for g in gaps if g >= 200)
|
||||
|
||||
for phase in ("renderer", "yoga", "diff", "write"):
|
||||
vals = [f["phases"][phase] for f in frames if f.get("phases")]
|
||||
if vals:
|
||||
metrics[f"{phase}_p99"] = pct(vals, 0.99)
|
||||
metrics[f"{phase}_max"] = max(vals)
|
||||
|
||||
patches = [f["phases"]["patches"] for f in frames if f.get("phases")]
|
||||
if patches:
|
||||
metrics["patches_total"] = sum(patches)
|
||||
metrics["patches_p99"] = pct(patches, 0.99)
|
||||
|
||||
optimized = [
|
||||
f["phases"].get("optimizedPatches", 0) for f in frames if f.get("phases")
|
||||
]
|
||||
if any(optimized):
|
||||
metrics["optimized_total"] = sum(optimized)
|
||||
|
||||
bytes_list = [
|
||||
f["phases"].get("writeBytes", 0) for f in frames if f.get("phases")
|
||||
]
|
||||
if any(bytes_list):
|
||||
metrics["writeBytes_total"] = sum(bytes_list)
|
||||
|
||||
drains = [
|
||||
f["phases"].get("prevFrameDrainMs", 0)
|
||||
for f in frames if f.get("phases")
|
||||
]
|
||||
drain_nonzero = [d for d in drains if d > 0]
|
||||
if drain_nonzero:
|
||||
metrics["drain_p99"] = pct(drain_nonzero, 0.99)
|
||||
metrics["drain_max"] = max(drain_nonzero)
|
||||
|
||||
bp = sum(1 for f in frames if f.get("phases", {}).get("backpressure"))
|
||||
metrics["backpressure_frames"] = bp
|
||||
|
||||
if react:
|
||||
for pid in set(e["id"] for e in react):
|
||||
ms = [e["actualMs"] for e in react if e["id"] == pid]
|
||||
metrics[f"react_{pid}_p99"] = pct(ms, 0.99)
|
||||
metrics[f"react_{pid}_max"] = max(ms)
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def format_diff(before: dict[str, float], after: dict[str, float]) -> str:
|
||||
"""Render a side-by-side A/B comparison table."""
|
||||
keys = sorted(set(before) | set(after))
|
||||
lines = [f"{'metric':<28} {'before':>12} {'after':>12} {'delta':>12} {'%':>6}"]
|
||||
lines.append("─" * 76)
|
||||
for k in keys:
|
||||
b = before.get(k, 0.0)
|
||||
a = after.get(k, 0.0)
|
||||
d = a - b
|
||||
pct_change = ((a / b) - 1) * 100 if b not in (0, 0.0) else float("inf") if a else 0
|
||||
|
||||
# Flag improvements vs regressions. For _p99 / _max / _total / gaps_over /
|
||||
# patches / writeBytes / backpressure, LOWER is better. For fps / gaps_under,
|
||||
# HIGHER is better.
|
||||
lower_is_better = any(
|
||||
token in k
|
||||
for token in (
|
||||
"p50",
|
||||
"p95",
|
||||
"p99",
|
||||
"_max",
|
||||
"_total",
|
||||
"gaps_over",
|
||||
"backpressure",
|
||||
"drain",
|
||||
)
|
||||
)
|
||||
higher_is_better = "fps_" in k or "gaps_under" in k
|
||||
mark = ""
|
||||
if d and not (lower_is_better or higher_is_better):
|
||||
mark = ""
|
||||
elif d < 0 and lower_is_better:
|
||||
mark = "↓"
|
||||
elif d > 0 and higher_is_better:
|
||||
mark = "↑"
|
||||
elif d > 0 and lower_is_better:
|
||||
mark = "↑" # regression
|
||||
elif d < 0 and higher_is_better:
|
||||
mark = "↓" # regression
|
||||
|
||||
pct_str = "—" if pct_change == float("inf") else f"{pct_change:+6.1f}%"
|
||||
lines.append(
|
||||
f"{k:<28} {b:>12.2f} {a:>12.2f} {d:>+12.2f} {pct_str} {mark}"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def run_once(args: argparse.Namespace) -> dict[str, Any]:
|
||||
tui_dir = Path(args.tui_dir).resolve()
|
||||
entry = tui_dir / "dist" / "entry.js"
|
||||
if not entry.exists():
|
||||
sys.exit(f"{entry} missing — run `npm run build` in {tui_dir} first")
|
||||
|
||||
sid = args.session or pick_longest_session(DEFAULT_STATE_DB)
|
||||
print(f"• session: {sid}")
|
||||
print(f"• hold: {args.hold} x {args.rate}Hz for {args.seconds}s after {args.warmup}s warmup")
|
||||
print(f"• terminal: {args.cols}x{args.rows}")
|
||||
|
||||
log = Path(args.log)
|
||||
if not args.keep_log and log.exists():
|
||||
log.unlink()
|
||||
|
||||
since_ms = int(time.time() * 1000)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["HERMES_DEV_PERF"] = "1"
|
||||
env["HERMES_DEV_PERF_MS"] = str(args.threshold_ms)
|
||||
env["HERMES_DEV_PERF_LOG"] = str(log)
|
||||
env["HERMES_TUI_RESUME"] = sid
|
||||
env["COLUMNS"] = str(args.cols)
|
||||
env["LINES"] = str(args.rows)
|
||||
env["TERM"] = env.get("TERM", "xterm-256color")
|
||||
|
||||
# Pass through extra flags the TUI wrapper recognizes (e.g. --no-fullscreen).
|
||||
# Stored on args as `extra_flags` list.
|
||||
node = os.environ.get("HERMES_PERF_NODE", "node")
|
||||
node_args = [node, str(entry), *getattr(args, "extra_flags", [])]
|
||||
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.execvpe(node, node_args, env)
|
||||
|
||||
try:
|
||||
import fcntl, struct, termios
|
||||
winsize = struct.pack("HHHH", args.rows, args.cols, 0, 0)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
||||
|
||||
print(f"• pid: {pid} fd: {fd}")
|
||||
print(f"• warmup {args.warmup}s (drain startup output)…")
|
||||
drain(fd, args.warmup)
|
||||
|
||||
print(f"• holding {args.hold}…")
|
||||
sent = hold_key(fd, KEYS[args.hold], args.seconds, args.rate)
|
||||
print(f" sent {sent} keystrokes")
|
||||
|
||||
drain(fd, 0.5)
|
||||
finally:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
for _ in range(10):
|
||||
pid_done, _ = os.waitpid(pid, os.WNOHANG)
|
||||
if pid_done == pid:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
os.waitpid(pid, 0)
|
||||
except (ProcessLookupError, ChildProcessError):
|
||||
pass
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
time.sleep(0.2)
|
||||
return summarize(log, since_ms)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--session", help="session id to resume (default: longest in db)")
|
||||
p.add_argument("--hold", default="page_up", choices=sorted(KEYS.keys()), help="key to hold")
|
||||
p.add_argument("--seconds", type=float, default=8.0, help="how long to hold the key")
|
||||
p.add_argument("--rate", type=int, default=30, help="keystrokes per second")
|
||||
p.add_argument("--warmup", type=float, default=3.0, help="seconds to wait after launch before input")
|
||||
p.add_argument("--threshold-ms", type=float, default=0.0, help="HERMES_DEV_PERF_MS (0 = capture all)")
|
||||
p.add_argument("--cols", type=int, default=120)
|
||||
p.add_argument("--rows", type=int, default=40)
|
||||
p.add_argument("--keep-log", action="store_true", help="don't wipe perf.log before run")
|
||||
p.add_argument("--tui-dir", default=str(DEFAULT_TUI_DIR))
|
||||
p.add_argument("--log", default=str(DEFAULT_LOG))
|
||||
p.add_argument("--save", metavar="LABEL",
|
||||
help="save the final metrics as /tmp/perf-<LABEL>.json for later --compare")
|
||||
p.add_argument("--compare", metavar="LABEL",
|
||||
help="diff against /tmp/perf-<LABEL>.json after running")
|
||||
p.add_argument("--loop", action="store_true",
|
||||
help="watch for source changes, rebuild, rerun, and diff vs previous run")
|
||||
p.add_argument("--extra-flag", dest="extra_flags", action="append", default=[],
|
||||
help="pass through to node dist/entry.js (repeatable)")
|
||||
args = p.parse_args()
|
||||
|
||||
if args.loop:
|
||||
return loop_mode(args)
|
||||
|
||||
# Single-shot path.
|
||||
data = run_once(args)
|
||||
print()
|
||||
print(format_report(data))
|
||||
|
||||
metrics = key_metrics(data)
|
||||
|
||||
if args.save:
|
||||
path = Path(f"/tmp/perf-{args.save}.json")
|
||||
path.write_text(json.dumps(metrics, indent=2))
|
||||
print(f"\n• saved: {path}")
|
||||
|
||||
if args.compare:
|
||||
path = Path(f"/tmp/perf-{args.compare}.json")
|
||||
if not path.exists():
|
||||
print(f"\n⚠ no baseline at {path} — run with --save {args.compare} first")
|
||||
else:
|
||||
before = json.loads(path.read_text())
|
||||
print(f"\n═══ A/B diff vs /tmp/perf-{args.compare}.json ═══")
|
||||
print(format_diff(before, metrics))
|
||||
|
||||
if not data["react"] and not data["frame"]:
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
def loop_mode(args: argparse.Namespace) -> int:
|
||||
"""Watch source files, rebuild, rerun, print A/B diff against previous run.
|
||||
|
||||
Keeps a rolling 'previous run' baseline in memory so each iteration
|
||||
reports delta vs the last one — visibility into whether the last
|
||||
edit moved the needle. Press Ctrl+C to stop.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
tui_dir = Path(args.tui_dir).resolve()
|
||||
src_root = tui_dir / "src"
|
||||
pkg_root = tui_dir / "packages" / "hermes-ink" / "src"
|
||||
|
||||
def collect_mtimes() -> dict[str, float]:
|
||||
mtimes: dict[str, float] = {}
|
||||
for root in (src_root, pkg_root):
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in root.rglob("*"):
|
||||
if path.suffix in {".ts", ".tsx"} and "__tests__" not in str(path):
|
||||
try:
|
||||
mtimes[str(path)] = path.stat().st_mtime
|
||||
except OSError:
|
||||
pass
|
||||
return mtimes
|
||||
|
||||
previous_metrics: dict[str, float] | None = None
|
||||
previous_mtimes = collect_mtimes()
|
||||
iteration = 0
|
||||
|
||||
print(f"• loop mode — watching {src_root} + {pkg_root} for *.ts(x) changes")
|
||||
print("• edit any TS file, the harness rebuilds + reruns automatically")
|
||||
print("• Ctrl+C to stop\n")
|
||||
|
||||
try:
|
||||
while True:
|
||||
iteration += 1
|
||||
print(f"\n{'═' * 76}")
|
||||
print(f"Iteration {iteration} @ {time.strftime('%H:%M:%S')}")
|
||||
print("═" * 76)
|
||||
|
||||
if iteration > 1:
|
||||
print("• rebuilding…")
|
||||
result = subprocess.run(
|
||||
["npm", "run", "build"],
|
||||
cwd=tui_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print("✗ build failed:")
|
||||
print(result.stdout[-2000:])
|
||||
print(result.stderr[-2000:])
|
||||
print("\n• waiting for source changes to retry…")
|
||||
previous_mtimes = wait_for_change(previous_mtimes, collect_mtimes)
|
||||
continue
|
||||
print("✓ build ok")
|
||||
|
||||
data = run_once(args)
|
||||
metrics = key_metrics(data)
|
||||
|
||||
print()
|
||||
print(format_report(data))
|
||||
|
||||
if previous_metrics is not None:
|
||||
print(f"\n═══ A/B diff vs iteration {iteration - 1} ═══")
|
||||
print(format_diff(previous_metrics, metrics))
|
||||
|
||||
previous_metrics = metrics
|
||||
|
||||
print("\n• waiting for source changes…")
|
||||
previous_mtimes = wait_for_change(previous_mtimes, collect_mtimes)
|
||||
except KeyboardInterrupt:
|
||||
print("\n• loop stopped")
|
||||
return 0
|
||||
|
||||
|
||||
def wait_for_change(prev: dict[str, float], collect) -> dict[str, float]:
|
||||
"""Poll every 1s until a watched file's mtime changes. Debounced 500ms."""
|
||||
while True:
|
||||
time.sleep(1)
|
||||
current = collect()
|
||||
|
||||
changed = [
|
||||
path for path, mtime in current.items() if prev.get(path) != mtime
|
||||
]
|
||||
|
||||
if changed:
|
||||
print(f" ↻ {len(changed)} file(s) changed:")
|
||||
for path in changed[:5]:
|
||||
print(f" {path}")
|
||||
# Debounce — editor save bursts can take ~500ms to settle
|
||||
time.sleep(0.5)
|
||||
return collect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -43,16 +43,22 @@ AUTHOR_MAP = {
|
||||
"teknium1@gmail.com": "teknium1",
|
||||
"teknium@nousresearch.com": "teknium1",
|
||||
"127238744+teknium1@users.noreply.github.com": "teknium1",
|
||||
"johnnncenaaa77@gmail.com": "johnncenae",
|
||||
"focusflow.app.help@gmail.com": "yes999zc",
|
||||
"343873859@qq.com": "DrStrangerUJN",
|
||||
"uzmpsk.dilekakbas@gmail.com": "dlkakbs",
|
||||
"jefferson@heimdallstrategy.com": "Mind-Dragon",
|
||||
"130918800+devorun@users.noreply.github.com": "devorun",
|
||||
"sonoyuncudmr@gmail.com": "Sonoyunchu",
|
||||
"maks.mir@yahoo.com": "say8hi",
|
||||
"web3blind@users.noreply.github.com": "web3blind",
|
||||
"julia@alexland.us": "alexg0bot",
|
||||
"1060770+benjaminsehl@users.noreply.github.com": "benjaminsehl",
|
||||
"nerijusn76@gmail.com": "Nerijusas",
|
||||
"itonov@proton.me": "Ito-69",
|
||||
"glesstech@gmail.com": "georgeglessner",
|
||||
"maxim.smetanin@gmail.com": "maxims-oss",
|
||||
"yoimexex@gmail.com": "Yoimex",
|
||||
# contributors (from noreply pattern)
|
||||
"david.vv@icloud.com": "davidvv",
|
||||
"wangqiang@wangqiangdeMac-mini.local": "xiaoqiang243",
|
||||
@@ -67,9 +73,12 @@ AUTHOR_MAP = {
|
||||
"thomasgeorgevii09@gmail.com": "tochukwuada",
|
||||
"harryykyle1@gmail.com": "hharry11",
|
||||
"kshitijk4poor@gmail.com": "kshitijk4poor",
|
||||
"1294707+Tosko4@users.noreply.github.com": "Tosko4",
|
||||
"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 +125,22 @@ 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",
|
||||
"110560187+Wang-tianhao@users.noreply.github.com": "Wang-tianhao",
|
||||
"170458616+ghostmfr@users.noreply.github.com": "ghostmfr",
|
||||
"1848670+mewwts@users.noreply.github.com": "mewwts",
|
||||
"1930707+haru398801@users.noreply.github.com": "haru398801",
|
||||
"rapabelias@gmail.com": "badgerbees",
|
||||
"xnb888@proton.me": "xnbi",
|
||||
"xiahu889889@proton.me": "xiahu88988",
|
||||
"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 +213,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",
|
||||
@@ -377,6 +400,17 @@ AUTHOR_MAP = {
|
||||
"zzn+pa@zzn.im": "xinbenlv",
|
||||
"zaynjarvis@gmail.com": "ZaynJarvis",
|
||||
"zhiheng.liu@bytedance.com": "ZaynJarvis",
|
||||
"izhaolongfei@gmail.com": "loongfay",
|
||||
"296659110@qq.com": "lrt4836",
|
||||
"fe.daniel91@gmail.com": "beforeload",
|
||||
"libo1106@foxmail.com": "libo1106",
|
||||
"295367131@qq.com": "295367131",
|
||||
"295367132@qq.com": "IxAres",
|
||||
"danieldliu@tencent.com": "danieldliu",
|
||||
"loongzhao@tencent.com": "loongzhao",
|
||||
"Bartok9@users.noreply.github.com": "Bartok9",
|
||||
"LeonSGP43@users.noreply.github.com": "LeonSGP43",
|
||||
"kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
|
||||
"mbelleau@Michels-MacBook-Pro.local": "malaiwah",
|
||||
"michel.belleau@malaiwah.com": "malaiwah",
|
||||
"gnanasekaran.sekareee@gmail.com": "gnanam1990",
|
||||
|
||||
@@ -402,6 +402,63 @@ Tool changes take effect on `/reset` (new session). They do NOT apply mid-conver
|
||||
|
||||
---
|
||||
|
||||
## Security & Privacy Toggles
|
||||
|
||||
Common "why is Hermes doing X to my output / tool calls / commands?" toggles — and the exact commands to change them. Most of these need a fresh session (`/reset` in chat, or start a new `hermes` invocation) because they're read once at startup.
|
||||
|
||||
### Secret redaction in tool output
|
||||
|
||||
Hermes auto-redacts strings that look like API keys, tokens, and secrets in all tool output (terminal stdout, `read_file`, web content, subagent summaries, etc.) so the model never sees raw credentials. If the user is intentionally working with mock tokens, share-management tokens, or their own secrets and the redaction is getting in the way:
|
||||
|
||||
```bash
|
||||
hermes config set security.redact_secrets false # disable globally
|
||||
```
|
||||
|
||||
**Restart required.** `security.redact_secrets` is snapshotted at import time — setting it mid-session (e.g. via `export HERMES_REDACT_SECRETS=false` from a tool call) will NOT take effect for the running process. Tell the user to run `hermes config set security.redact_secrets false` in a terminal, then start a new session. This is deliberate — it prevents an LLM from turning off redaction on itself mid-task.
|
||||
|
||||
Re-enable with:
|
||||
```bash
|
||||
hermes config set security.redact_secrets true
|
||||
```
|
||||
|
||||
### PII redaction in gateway messages
|
||||
|
||||
Separate from secret redaction. When enabled, the gateway hashes user IDs and strips phone numbers from the session context before it reaches the model:
|
||||
|
||||
```bash
|
||||
hermes config set privacy.redact_pii true # enable
|
||||
hermes config set privacy.redact_pii false # disable (default)
|
||||
```
|
||||
|
||||
### Command approval prompts
|
||||
|
||||
By default (`approvals.mode: manual`), Hermes prompts the user before running shell commands flagged as destructive (`rm -rf`, `git reset --hard`, etc.). The modes are:
|
||||
|
||||
- `manual` — always prompt (default)
|
||||
- `smart` — use an auxiliary LLM to auto-approve low-risk commands, prompt on high-risk
|
||||
- `off` — skip all approval prompts (equivalent to `--yolo`)
|
||||
|
||||
```bash
|
||||
hermes config set approvals.mode smart # recommended middle ground
|
||||
hermes config set approvals.mode off # bypass everything (not recommended)
|
||||
```
|
||||
|
||||
Per-invocation bypass without changing config:
|
||||
- `hermes --yolo …`
|
||||
- `export HERMES_YOLO_MODE=1`
|
||||
|
||||
Note: YOLO / `approvals.mode: off` does NOT turn off secret redaction. They are independent.
|
||||
|
||||
### Shell hooks allowlist
|
||||
|
||||
Some shell-hook integrations require explicit allowlisting before they fire. Managed via `~/.hermes/shell-hooks-allowlist.json` — prompted interactively the first time a hook wants to run.
|
||||
|
||||
### Disabling the web/browser/image-gen tools
|
||||
|
||||
To keep the model away from network or media tools entirely, open `hermes tools` and toggle per-platform. Takes effect on next session (`/reset`). See the Tools & Skills section above.
|
||||
|
||||
---
|
||||
|
||||
## Voice & Transcription
|
||||
|
||||
### STT (Voice → Text)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
description: Skills for monitoring, aggregating, and processing RSS feeds, blogs, and web content sources.
|
||||
---
|
||||
@@ -0,0 +1,228 @@
|
||||
---
|
||||
name: airtable
|
||||
description: Airtable REST API via curl. Records CRUD, filters, upserts.
|
||||
version: 1.1.0
|
||||
author: community
|
||||
license: MIT
|
||||
prerequisites:
|
||||
env_vars: [AIRTABLE_API_KEY]
|
||||
commands: [curl]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Airtable, Productivity, Database, API]
|
||||
homepage: https://airtable.com/developers/web/api/introduction
|
||||
---
|
||||
|
||||
# Airtable — Bases, Tables & Records
|
||||
|
||||
Work with Airtable's REST API directly via `curl` using the `terminal` tool. No MCP server, no OAuth flow, no Python SDK — just `curl` and a personal access token.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Create a **Personal Access Token (PAT)** at https://airtable.com/create/tokens (tokens start with `pat...`).
|
||||
2. Grant these scopes (minimum):
|
||||
- `data.records:read` — read rows
|
||||
- `data.records:write` — create / update / delete rows
|
||||
- `schema.bases:read` — list bases and tables
|
||||
3. **Important:** in the same token UI, add each base you want to access to the token's **Access** list. PATs are scoped per-base — a valid token on the wrong base returns `403`.
|
||||
4. Store the token in `~/.hermes/.env` (or via `hermes setup`):
|
||||
```
|
||||
AIRTABLE_API_KEY=pat_your_token_here
|
||||
```
|
||||
|
||||
> Note: legacy `key...` API keys were deprecated Feb 2024. Only PATs and OAuth tokens work now.
|
||||
|
||||
## API Basics
|
||||
|
||||
- **Endpoint:** `https://api.airtable.com/v0`
|
||||
- **Auth header:** `Authorization: Bearer $AIRTABLE_API_KEY`
|
||||
- **All requests** use JSON (`Content-Type: application/json` for any POST/PATCH/PUT body).
|
||||
- **Object IDs:** bases `app...`, tables `tbl...`, records `rec...`, fields `fld...`. IDs never change; names can. Prefer IDs in automations.
|
||||
- **Rate limit:** 5 requests/sec/base. `429` → back off. Burst on a single base will be throttled.
|
||||
|
||||
Base curl pattern:
|
||||
```bash
|
||||
curl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE?maxRecords=5" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
|
||||
`-s` suppresses curl's progress bar — keep it set for every call so the tool output stays clean for Hermes. Pipe through `python3 -m json.tool` (always present) or `jq` (if installed) for readable JSON.
|
||||
|
||||
## Field Types (request body shapes)
|
||||
|
||||
| Field type | Write shape |
|
||||
|---|---|
|
||||
| Single line text | `"Name": "hello"` |
|
||||
| Long text | `"Notes": "multi\nline"` |
|
||||
| Number | `"Score": 42` |
|
||||
| Checkbox | `"Done": true` |
|
||||
| Single select | `"Status": "Todo"` (name must already exist unless `typecast: true`) |
|
||||
| Multi-select | `"Tags": ["urgent", "bug"]` |
|
||||
| Date | `"Due": "2026-04-01"` |
|
||||
| DateTime (UTC) | `"At": "2026-04-01T14:30:00.000Z"` |
|
||||
| URL / Email / Phone | `"Link": "https://…"` |
|
||||
| Attachment | `"Files": [{"url": "https://…"}]` (Airtable fetches + rehosts) |
|
||||
| Linked record | `"Owner": ["recXXXXXXXXXXXXXX"]` (array of record IDs) |
|
||||
| User | `"AssignedTo": {"id": "usrXXXXXXXXXXXXXX"}` |
|
||||
|
||||
Pass `"typecast": true` at the top level of a create/update body to let Airtable auto-coerce values (e.g. create a new select option on the fly, convert `"42"` → `42`).
|
||||
|
||||
## Common Queries
|
||||
|
||||
### List bases the token can see
|
||||
```bash
|
||||
curl -s "https://api.airtable.com/v0/meta/bases" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
|
||||
### List tables + schema for a base
|
||||
```bash
|
||||
curl -s "https://api.airtable.com/v0/meta/bases/$BASE_ID/tables" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
Use this BEFORE mutating — confirms exact field names and IDs, surfaces `options.choices` for select fields, and shows primary-field names.
|
||||
|
||||
### List records (first 10)
|
||||
```bash
|
||||
curl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE?maxRecords=10" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Get a single record
|
||||
```bash
|
||||
curl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE/$RECORD_ID" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Filter records (filterByFormula)
|
||||
Airtable formulas must be URL-encoded. Let Python stdlib do it — never hand-encode:
|
||||
```bash
|
||||
FORMULA="{Status}='Todo'"
|
||||
ENC=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$FORMULA")
|
||||
curl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE?filterByFormula=$ENC&maxRecords=20" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
|
||||
Useful formula patterns:
|
||||
- Exact match: `{Email}='user@example.com'`
|
||||
- Contains: `FIND('bug', LOWER({Title}))`
|
||||
- Multiple conditions: `AND({Status}='Todo', {Priority}='High')`
|
||||
- Or: `OR({Owner}='alice', {Owner}='bob')`
|
||||
- Not empty: `NOT({Assignee}='')`
|
||||
- Date comparison: `IS_AFTER({Due}, TODAY())`
|
||||
|
||||
### Sort + select specific fields
|
||||
```bash
|
||||
curl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE?sort%5B0%5D%5Bfield%5D=Priority&sort%5B0%5D%5Bdirection%5D=asc&fields%5B%5D=Name&fields%5B%5D=Status" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
Square brackets in query params MUST be URL-encoded (`%5B` / `%5D`).
|
||||
|
||||
### Use a named view
|
||||
```bash
|
||||
curl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE?view=Grid%20view&maxRecords=50" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
Views apply their saved filter + sort server-side.
|
||||
|
||||
## Common Mutations
|
||||
|
||||
### Create a record
|
||||
```bash
|
||||
curl -s -X POST "https://api.airtable.com/v0/$BASE_ID/$TABLE" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"fields":{"Name":"New task","Status":"Todo","Priority":"High"}}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Create up to 10 records in one call
|
||||
```bash
|
||||
curl -s -X POST "https://api.airtable.com/v0/$BASE_ID/$TABLE" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"typecast": true,
|
||||
"records": [
|
||||
{"fields": {"Name": "Task A", "Status": "Todo"}},
|
||||
{"fields": {"Name": "Task B", "Status": "In progress"}}
|
||||
]
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
Batch endpoints are capped at **10 records per request**. For larger inserts, loop in batches of 10 with a short sleep to respect 5 req/sec/base.
|
||||
|
||||
### Update a record (PATCH — merges, preserves unchanged fields)
|
||||
```bash
|
||||
curl -s -X PATCH "https://api.airtable.com/v0/$BASE_ID/$TABLE/$RECORD_ID" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"fields":{"Status":"Done"}}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Upsert by a merge field (no ID needed)
|
||||
```bash
|
||||
curl -s -X PATCH "https://api.airtable.com/v0/$BASE_ID/$TABLE" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"performUpsert": {"fieldsToMergeOn": ["Email"]},
|
||||
"records": [
|
||||
{"fields": {"Email": "user@example.com", "Status": "Active"}}
|
||||
]
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
`performUpsert` creates records whose merge-field values are new, patches records whose merge-field values already exist. Great for idempotent syncs.
|
||||
|
||||
### Delete a record
|
||||
```bash
|
||||
curl -s -X DELETE "https://api.airtable.com/v0/$BASE_ID/$TABLE/$RECORD_ID" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Delete up to 10 records in one call
|
||||
```bash
|
||||
curl -s -X DELETE "https://api.airtable.com/v0/$BASE_ID/$TABLE?records%5B%5D=rec1&records%5B%5D=rec2" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
List endpoints return at most **100 records per page**. If the response includes `"offset": "..."`, pass it back on the next call. Loop until the field is absent:
|
||||
|
||||
```bash
|
||||
OFFSET=""
|
||||
while :; do
|
||||
URL="https://api.airtable.com/v0/$BASE_ID/$TABLE?pageSize=100"
|
||||
[ -n "$OFFSET" ] && URL="$URL&offset=$OFFSET"
|
||||
RESP=$(curl -s "$URL" -H "Authorization: Bearer $AIRTABLE_API_KEY")
|
||||
echo "$RESP" | python3 -c 'import json,sys; d=json.load(sys.stdin); [print(r["id"], r["fields"].get("Name","")) for r in d["records"]]'
|
||||
OFFSET=$(echo "$RESP" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("offset",""))')
|
||||
[ -z "$OFFSET" ] && break
|
||||
done
|
||||
```
|
||||
|
||||
## Typical Hermes Workflow
|
||||
|
||||
1. **Confirm auth.** `curl -s -o /dev/null -w "%{http_code}\n" https://api.airtable.com/v0/meta/bases -H "Authorization: Bearer $AIRTABLE_API_KEY"` — expect `200`.
|
||||
2. **Find the base.** List bases (step above) OR ask the user for the `app...` ID directly if the token lacks `schema.bases:read`.
|
||||
3. **Inspect the schema.** `GET /v0/meta/bases/$BASE_ID/tables` — cache the exact field names and primary-field name locally in the session before mutating anything.
|
||||
4. **Read before you write.** For "update X where Y", `filterByFormula` first to resolve the `rec...` ID, then `PATCH /v0/$BASE_ID/$TABLE/$RECORD_ID`. Never guess record IDs.
|
||||
5. **Batch writes.** Combine related creates into one 10-record POST to stay under the 5 req/sec budget.
|
||||
6. **Destructive ops.** Deletions can't be undone via API. If the user says "delete all Xs", echo back the filter + record count and confirm before firing.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **`filterByFormula` MUST be URL-encoded.** Field names with spaces or non-ASCII also need encoding (`{My Field}` → `%7BMy%20Field%7D`). Use Python stdlib (pattern above) — never hand-escape.
|
||||
- **Empty fields are omitted from responses.** A missing `"Assignee"` key doesn't mean the field doesn't exist — it means this record's value is empty. Check the schema (step 3) before concluding a field is missing.
|
||||
- **PATCH vs PUT.** `PATCH` merges supplied fields into the record. `PUT` replaces the record entirely and clears any field you didn't include. Default to `PATCH`.
|
||||
- **Single-select options must exist.** Writing `"Status": "Shipping"` when `Shipping` isn't in the field's option list errors with `INVALID_MULTIPLE_CHOICE_OPTIONS` unless you pass `"typecast": true` (which auto-creates the option).
|
||||
- **Per-base token scoping.** A `403` on one base while another works means the token's Access list doesn't include that base — not a scope or auth issue. Send the user to https://airtable.com/create/tokens to grant it.
|
||||
- **Rate limits are per base, not per token.** 5 req/sec on `baseA` and 5 req/sec on `baseB` is fine; 6 req/sec on `baseA` alone will throttle. Monitor the `Retry-After` header on `429`.
|
||||
|
||||
## Important Notes for Hermes
|
||||
|
||||
- **Always use the `terminal` tool with `curl`.** Do NOT use `web_extract` (it can't send auth headers) or `browser_navigate` (needs UI auth and is slow).
|
||||
- **`AIRTABLE_API_KEY` flows from `~/.hermes/.env` into the subprocess automatically** when this skill is loaded — no need to re-export it before each `curl` call.
|
||||
- **Escape curly braces in formulas carefully.** In a heredoc body, `{Status}` is literal. In a shell argument, `{Status}` is safe outside `{...}` brace-expansion context — but pass dynamic strings through `python3 urllib.parse.quote` before splicing into a URL.
|
||||
- **Pretty-print with `python3 -m json.tool`** (always present) rather than `jq` (optional). Only reach for `jq` when you need filtering/projection.
|
||||
- **Pagination is per-page, not global.** Airtable's 100-record cap is a hard limit; there is no way to bump it. Loop with `offset` until the field is absent.
|
||||
- **Read the `errors` array** on non-2xx responses — Airtable returns structured error codes like `AUTHENTICATION_REQUIRED`, `INVALID_PERMISSIONS`, `MODEL_ID_NOT_FOUND`, `INVALID_MULTIPLE_CHOICE_OPTIONS` that tell you exactly what's wrong.
|
||||
@@ -289,6 +289,7 @@ def exchange_auth_code(code: str):
|
||||
sys.exit(1)
|
||||
|
||||
pending_auth = _load_pending_auth()
|
||||
raw_callback = code
|
||||
code, returned_state = _extract_code_and_state(code)
|
||||
if returned_state and returned_state != pending_auth["state"]:
|
||||
print("ERROR: OAuth state mismatch. Run --auth-url again to start a fresh session.")
|
||||
@@ -298,19 +299,13 @@ def exchange_auth_code(code: str):
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
# Extract granted scopes from the callback URL if present
|
||||
if returned_state and "scope" in parse_qs(urlparse(code).query if isinstance(code, str) and code.startswith("http") else {}):
|
||||
granted_scopes = parse_qs(urlparse(code).query)["scope"][0].split()
|
||||
else:
|
||||
# Try to extract from code_or_url parameter
|
||||
if isinstance(code, str) and code.startswith("http"):
|
||||
params = parse_qs(urlparse(code).query)
|
||||
if "scope" in params:
|
||||
granted_scopes = params["scope"][0].split()
|
||||
else:
|
||||
granted_scopes = SCOPES
|
||||
else:
|
||||
granted_scopes = SCOPES
|
||||
# Extract granted scopes from the callback URL if the user pasted the full redirect URL.
|
||||
granted_scopes = list(SCOPES)
|
||||
if isinstance(raw_callback, str) and raw_callback.startswith("http"):
|
||||
params = parse_qs(urlparse(raw_callback).query)
|
||||
scope_val = (params.get("scope") or [""])[0].strip()
|
||||
if scope_val:
|
||||
granted_scopes = scope_val.split()
|
||||
|
||||
flow = Flow.from_client_secrets_file(
|
||||
str(CLIENT_SECRET_PATH),
|
||||
|
||||
@@ -926,13 +926,18 @@ def cmd_timezone(args):
|
||||
os_ = offset_info.get("seconds", 0)
|
||||
sign = "+" if oh >= 0 else "-"
|
||||
utc_offset = f"{sign}{abs(oh):02d}:{om:02d}"
|
||||
if os_:
|
||||
utc_offset = f"{utc_offset}:{os_:02d}"
|
||||
elif tz_data.get("standardUtcOffset"):
|
||||
offset_info2 = tz_data["standardUtcOffset"]
|
||||
if isinstance(offset_info2, dict):
|
||||
oh = offset_info2.get("hours", 0)
|
||||
om = abs(offset_info2.get("minutes", 0))
|
||||
os_ = offset_info2.get("seconds", 0)
|
||||
sign = "+" if oh >= 0 else "-"
|
||||
utc_offset = f"{sign}{abs(oh):02d}:{om:02d}"
|
||||
if os_:
|
||||
utc_offset = f"{utc_offset}:{os_:02d}"
|
||||
timezone_src = "timeapi.io"
|
||||
except (RuntimeError, KeyError, TypeError):
|
||||
pass # API may be down; continue to fallback
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
name: debugging-hermes-tui-commands
|
||||
description: Use when debugging or adding Hermes TUI slash commands across the Python backend (hermes_cli/commands.py), the tui_gateway bridge, and the TypeScript/Ink frontend. Covers autocomplete gaps, gateway dispatch issues, and live UI-state wiring.
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [debugging, hermes-agent, tui, slash-commands, typescript, python]
|
||||
related_skills: [python-debugpy, node-inspect-debugger, systematic-debugging]
|
||||
---
|
||||
|
||||
# Debugging Hermes TUI Slash Commands
|
||||
|
||||
## Overview
|
||||
|
||||
Hermes slash commands span three layers — Python command registry, tui_gateway JSON-RPC bridge, and the Ink/TypeScript frontend. When a command misbehaves (missing from autocomplete, works in CLI but not TUI, config persists but UI doesn't update), the bug is almost always one layer being out of sync with another.
|
||||
|
||||
Use this skill when you encounter issues with slash commands in the Hermes TUI, particularly when commands aren't showing in autocomplete, aren't working properly in the TUI, or need to be added/updated.
|
||||
|
||||
## When to Use
|
||||
|
||||
- A slash command exists in one part of the codebase but doesn't work fully
|
||||
- A command needs to be added to both backend and frontend
|
||||
- Command autocomplete isn't working for specific commands
|
||||
- Command behavior is inconsistent between CLI and TUI
|
||||
- A command persists config but doesn't apply live in the TUI
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Python backend (hermes_cli/commands.py) <- canonical COMMAND_REGISTRY
|
||||
│
|
||||
▼
|
||||
TUI gateway (tui_gateway/server.py) <- slash.exec / command.dispatch
|
||||
│
|
||||
▼
|
||||
TUI frontend (ui-tui/src/app/slash/) <- local handlers + fallthrough
|
||||
```
|
||||
|
||||
Command definitions must be registered consistently across Python and TypeScript to work properly. The Python `COMMAND_REGISTRY` is the source of truth for: CLI dispatch, gateway help, Telegram BotCommand menu, Slack subcommand map, and autocomplete data shipped to Ink.
|
||||
|
||||
## Investigation Steps
|
||||
|
||||
1. **Check if the command exists in the TUI frontend:**
|
||||
```bash
|
||||
search_files --pattern "/commandname" --file_glob "*.ts" --path ui-tui/
|
||||
search_files --pattern "/commandname" --file_glob "*.tsx" --path ui-tui/
|
||||
```
|
||||
|
||||
2. **Examine the TUI command definition:**
|
||||
```bash
|
||||
read_file ui-tui/src/app/slash/commands/core.ts
|
||||
# If not there:
|
||||
search_files --pattern "commandname" --path ui-tui/src/app/slash/commands --target files
|
||||
```
|
||||
|
||||
3. **Check if the command exists in the Python backend:**
|
||||
```bash
|
||||
search_files --pattern "CommandDef" --file_glob "*.py" --path hermes_cli/
|
||||
search_files --pattern "commandname" --path hermes_cli/commands.py --context 3
|
||||
```
|
||||
|
||||
4. **Examine the gateway implementation:**
|
||||
```bash
|
||||
search_files --pattern "complete.slash|slash.exec" --path tui_gateway/
|
||||
```
|
||||
|
||||
## Fix: Missing Command Autocomplete
|
||||
|
||||
If a command exists in the TUI but doesn't show in autocomplete:
|
||||
|
||||
1. Add a `CommandDef` entry to `COMMAND_REGISTRY` in `hermes_cli/commands.py`:
|
||||
```python
|
||||
CommandDef("commandname", "Description of the command", "Session",
|
||||
cli_only=True, aliases=("alias",),
|
||||
args_hint="[arg1|arg2|arg3]",
|
||||
subcommands=("arg1", "arg2", "arg3")),
|
||||
```
|
||||
|
||||
2. Pick `cli_only` vs gateway availability carefully:
|
||||
- `cli_only=True` — only in the interactive CLI/TUI
|
||||
- `gateway_only=True` — only in messaging platforms
|
||||
- neither — available everywhere
|
||||
- `gateway_config_gate="display.foo"` — config-gated availability in the gateway
|
||||
|
||||
3. Ensure `subcommands` matches the expected tab-completion options shown by the TUI.
|
||||
|
||||
4. If the command runs server-side, add a handler in `HermesCLI.process_command()` in `cli.py`:
|
||||
```python
|
||||
elif canonical == "commandname":
|
||||
self._handle_commandname(cmd_original)
|
||||
```
|
||||
|
||||
5. For gateway-available commands, add a handler in `gateway/run.py`:
|
||||
```python
|
||||
if canonical == "commandname":
|
||||
return await self._handle_commandname(event)
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
1. **Command shows in TUI but not in autocomplete.** The command is defined in the TUI codebase but missing from `COMMAND_REGISTRY` in `hermes_cli/commands.py`. Autocomplete data ships from Python.
|
||||
|
||||
2. **Command shows in autocomplete but doesn't work.** Check the command handler in `tui_gateway/server.py` and the frontend handler in `ui-tui/src/app/createSlashHandler.ts`. If the command is local-only in Ink, it must be handled in `app.tsx` built-in branch; otherwise it falls through to `slash.exec` and must have a Python handler.
|
||||
|
||||
3. **Command behavior differs between CLI and TUI.** The command might have different implementations. Check both `cli.py::process_command` and the TUI's local handler. Local TUI handlers take precedence over gateway dispatch.
|
||||
|
||||
4. **Command persists config but doesn't apply live.** For TUI-local commands, updating `config.set` is not enough. Also patch the relevant nanostore state immediately (usually `patchUiState(...)`) and pass any new state through rendering components. Example: `/details collapsed` must update live detail visibility, not just save `details_mode`; in-session global `/details <mode>` may need a separate command-override flag so live commands can override built-in section defaults while startup/config sync preserves default-expanded thinking/tools behavior.
|
||||
|
||||
5. **Gateway dispatch silently ignores the command.** The gateway only dispatches commands it knows about. Check `GATEWAY_KNOWN_COMMANDS` (derived from `COMMAND_REGISTRY` automatically) includes the canonical name. If the command is `cli_only` with a `gateway_config_gate`, verify the gated config value is truthy.
|
||||
|
||||
## Debugging Tactics
|
||||
|
||||
When surface-level inspection doesn't reveal the bug:
|
||||
|
||||
- **Python side hangs or misbehaves:** use the `python-debugpy` skill to break inside `_SlashWorker.exec` or the command handler. `remote-pdb` set at the handler entry is the fastest path.
|
||||
- **Ink side not reacting:** use the `node-inspect-debugger` skill to break in `app.tsx`'s slash dispatch or the local command branch. `sb('dist/app.js', <line>)` after `npm run build`.
|
||||
- **Registry mismatch / unclear which side is wrong:** compare the canonical `COMMAND_REGISTRY` entry against the TUI's local command list side-by-side.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Don't forget to set the appropriate category for the command in `CommandDef` (e.g., "Session", "Configuration", "Tools & Skills", "Info", "Exit")
|
||||
- Make sure any aliases are properly registered in the `aliases` tuple — no other file changes are needed, everything downstream (Telegram menu, Slack mapping, autocomplete, help) derives from it
|
||||
- For commands with subcommands, ensure the `subcommands` tuple in `CommandDef` matches what's in the TUI code
|
||||
- `cli_only=True` commands won't work in gateway/messaging platforms — unless you add a `gateway_config_gate` and the gate is truthy
|
||||
- After adding live UI state, search every consumer of the old prop/helper and thread the new state through all render paths, not just the active streaming path. TUI detail rendering has at least two important paths: live `StreamingAssistant`/`ToolTrail` and transcript/pending `MessageLine` rows. A `/clean` pass should explicitly check both.
|
||||
- Rebuild the TUI (`npm --prefix ui-tui run build`) before testing — tsx watch mode may lag on first launch
|
||||
|
||||
## Verification
|
||||
|
||||
After fixing:
|
||||
|
||||
1. Rebuild the TUI:
|
||||
```bash
|
||||
cd /home/bb/hermes-agent && npm --prefix ui-tui run build
|
||||
```
|
||||
|
||||
2. Run the TUI and test the command:
|
||||
```bash
|
||||
hermes --tui
|
||||
```
|
||||
|
||||
3. Type `/` and verify the command appears in autocomplete suggestions with the expected description and args hint.
|
||||
|
||||
4. Execute the command and confirm:
|
||||
- Expected behavior fires
|
||||
- Any persisted config updates correctly (`read_file ~/.hermes/config.yaml`)
|
||||
- Live UI state reflects the change immediately (not just after restart)
|
||||
|
||||
5. If the command is also gateway-available, test it from at least one messaging platform (or run the gateway tests: `scripts/run_tests.sh tests/gateway/`).
|
||||
@@ -0,0 +1,164 @@
|
||||
---
|
||||
name: hermes-agent-skill-authoring
|
||||
description: Use when authoring or updating a SKILL.md inside the hermes-agent repo itself (skills/ tree, committed to a branch). Covers required frontmatter, validator limits, peer-matching structure, and the write_file-vs-skill_manage distinction for in-repo skills.
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [skills, authoring, hermes-agent, conventions, skill-md]
|
||||
related_skills: [writing-plans, requesting-code-review]
|
||||
---
|
||||
|
||||
# Authoring Hermes-Agent Skills (in-repo)
|
||||
|
||||
## Overview
|
||||
|
||||
There are two places a SKILL.md can live:
|
||||
|
||||
1. **User-local:** `~/.hermes/skills/<maybe-category>/<name>/SKILL.md` — personal, not shared. Created via `skill_manage(action='create')`.
|
||||
2. **In-repo (this skill is about this case):** `/home/bb/hermes-agent/skills/<category>/<name>/SKILL.md` — committed, shipped with the package. Use `write_file` + `git add`. `skill_manage(action='create')` does NOT target this tree.
|
||||
|
||||
## When to Use
|
||||
|
||||
- User asks you to add a skill "in this branch / repo / commit"
|
||||
- You're committing a reusable workflow that should ship with hermes-agent
|
||||
- You're editing an existing skill under `/home/bb/hermes-agent/skills/` (use `patch` for small edits, `write_file` for rewrites; `skill_manage` still works for patch on in-repo skills, but not for `create`)
|
||||
|
||||
## Required Frontmatter
|
||||
|
||||
Source of truth: `tools/skill_manager_tool.py::_validate_frontmatter`. Hard requirements:
|
||||
|
||||
- Starts with `---` as the first bytes (no leading blank line).
|
||||
- Closes with `\n---\n` before the body.
|
||||
- Parses as a YAML mapping.
|
||||
- `name` field present.
|
||||
- `description` field present, ≤ **1024 chars** (`MAX_DESCRIPTION_LENGTH`).
|
||||
- Non-empty body after the closing `---`.
|
||||
|
||||
Peer-matched shape used by every skill under `skills/software-development/`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: my-skill-name # lowercase, hyphens, ≤64 chars (MAX_NAME_LENGTH)
|
||||
description: Use when <trigger>. <one-line behavior>.
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [short, descriptive, tags]
|
||||
related_skills: [other-skill, another-skill]
|
||||
---
|
||||
```
|
||||
|
||||
`version` / `author` / `license` / `metadata` are NOT enforced by the validator, but every peer has them — omit and your skill sticks out.
|
||||
|
||||
## Size Limits
|
||||
|
||||
- Description: ≤ 1024 chars (enforced).
|
||||
- Full SKILL.md: ≤ 100,000 chars (enforced as `MAX_SKILL_CONTENT_CHARS`, ~36k tokens).
|
||||
- Peer skills in `software-development/` sit at **8-14k chars**. Aim for that range. If you're pushing past 20k, split into `references/*.md` and reference them from SKILL.md.
|
||||
|
||||
## Peer-Matched Structure
|
||||
|
||||
Every in-repo skill follows roughly:
|
||||
|
||||
```
|
||||
# <Title>
|
||||
|
||||
## Overview
|
||||
One or two paragraphs: what and why.
|
||||
|
||||
## When to Use
|
||||
- Bulleted triggers
|
||||
- "Don't use for:" counter-triggers
|
||||
|
||||
## <Topic sections specific to the skill>
|
||||
- Quick-reference tables are common
|
||||
- Code blocks with exact commands
|
||||
- Hermes-specific recipes (tests via scripts/run_tests.sh, ui-tui paths, etc.)
|
||||
|
||||
## Common Pitfalls
|
||||
Numbered list of mistakes and their fixes.
|
||||
|
||||
## Verification Checklist
|
||||
- [ ] Checkbox list of post-action verifications
|
||||
|
||||
## One-Shot Recipes (optional)
|
||||
Named scenarios → concrete command sequences.
|
||||
```
|
||||
|
||||
Not every section is mandatory, but `Overview` + `When to Use` + actionable body + pitfalls are the minimum for the skill to feel like a peer.
|
||||
|
||||
## Directory Placement
|
||||
|
||||
```
|
||||
skills/<category>/<skill-name>/SKILL.md
|
||||
```
|
||||
|
||||
Categories currently in repo (confirm with `ls skills/`): `autonomous-ai-agents`, `creative`, `data-science`, `devops`, `dogfood`, `email`, `gaming`, `github`, `leisure`, `mcp`, `media`, `mlops/*`, `note-taking`, `productivity`, `red-teaming`, `research`, `smart-home`, `social-media`, `software-development`.
|
||||
|
||||
Pick the closest existing category. Don't invent new top-level categories casually.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Survey peers** in the target category:
|
||||
```
|
||||
ls skills/<category>/
|
||||
```
|
||||
Read 2-3 peer SKILL.md files to match tone and structure.
|
||||
2. **Check validator constraints** in `tools/skill_manager_tool.py` if unsure.
|
||||
3. **Draft** with `write_file` to `skills/<category>/<name>/SKILL.md`.
|
||||
4. **Validate locally**:
|
||||
```python
|
||||
import yaml, re, pathlib
|
||||
content = pathlib.Path("skills/<category>/<name>/SKILL.md").read_text()
|
||||
assert content.startswith("---")
|
||||
m = re.search(r'\n---\s*\n', content[3:])
|
||||
fm = yaml.safe_load(content[3:m.start()+3])
|
||||
assert "name" in fm and "description" in fm
|
||||
assert len(fm["description"]) <= 1024
|
||||
assert len(content) <= 100_000
|
||||
```
|
||||
5. **Git add + commit** on the active branch.
|
||||
6. **Note:** the CURRENT session's skill loader is cached — `skill_view` / `skills_list` will not see the new skill until a new session. This is expected, not a bug.
|
||||
|
||||
## Cross-Referencing Other Skills
|
||||
|
||||
`metadata.hermes.related_skills` unions both trees (`skills/` in-repo and `~/.hermes/skills/`) at load time. You CAN reference a user-local skill from an in-repo skill, but it won't resolve for other users who clone the repo fresh. Prefer referencing only in-repo skills from in-repo skills. If a frequently-referenced skill lives only in `~/.hermes/skills/`, consider promoting it to the repo.
|
||||
|
||||
## Editing Existing In-Repo Skills
|
||||
|
||||
- **Small fix (typo, added pitfall, tightened trigger):** `skill_manage(action='patch', name=..., old_string=..., new_string=...)` works fine on in-repo skills.
|
||||
- **Major rewrite:** `write_file` the whole SKILL.md. `skill_manage(action='edit')` also works but requires supplying the full new content.
|
||||
- **Adding supporting files:** `write_file` to `skills/<category>/<name>/references/<file>.md`, `templates/<file>`, or `scripts/<file>`. `skill_manage(action='write_file')` also works and enforces the references/templates/scripts/assets subdir allowlist.
|
||||
- **Always commit** the edit — in-repo skills are source, not runtime state.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Using `skill_manage(action='create')` for an in-repo skill.** It writes to `~/.hermes/skills/`, not the repo tree. Use `write_file` for in-repo creation.
|
||||
|
||||
2. **Leading whitespace before `---`.** The validator checks `content.startswith("---")`; any leading blank line or BOM fails validation.
|
||||
|
||||
3. **Description too generic.** Peer descriptions start with "Use when ..." and describe the *trigger class*, not the one task. "Use when debugging X" > "Debug X".
|
||||
|
||||
4. **Forgetting the author/license/metadata block.** Not validator-enforced, but every peer has it; omitting makes the skill look half-finished.
|
||||
|
||||
5. **Writing a skill that duplicates a peer.** Before creating, `ls skills/<category>/` and open 2-3 peers. Prefer extending an existing skill to creating a narrow sibling.
|
||||
|
||||
6. **Expecting the current session to see the new skill.** It won't. The skill loader is initialized at session start. Verify in a fresh session or via `skill_view` using the exact path.
|
||||
|
||||
7. **Linking to skills that don't exist in-repo.** `related_skills: [some-user-local-skill]` works for you but breaks for other clones. Prefer only in-repo links.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] File is at `skills/<category>/<name>/SKILL.md` (not in `~/.hermes/skills/`)
|
||||
- [ ] Frontmatter starts at byte 0 with `---`, closes with `\n---\n`
|
||||
- [ ] `name`, `description`, `version`, `author`, `license`, `metadata.hermes.{tags, related_skills}` all present
|
||||
- [ ] Name ≤ 64 chars, lowercase + hyphens
|
||||
- [ ] Description ≤ 1024 chars and starts with "Use when ..."
|
||||
- [ ] Total file ≤ 100,000 chars (aim for 8-15k)
|
||||
- [ ] Structure: `# Title` → `## Overview` → `## When to Use` → body → `## Common Pitfalls` → `## Verification Checklist`
|
||||
- [ ] `related_skills` references resolve in-repo (or are explicitly OK to be user-local)
|
||||
- [ ] `git add skills/<category>/<name>/ && git commit` completed on the intended branch
|
||||
@@ -0,0 +1,318 @@
|
||||
---
|
||||
name: node-inspect-debugger
|
||||
description: Use when debugging Node.js code (ui-tui, tui_gateway child processes, any Node script/test) with real breakpoints, stepping, scope inspection, and expression evaluation. Drives `node --inspect` via the Chrome DevTools Protocol from the terminal — no browser required.
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [debugging, nodejs, node-inspect, cdp, breakpoints, ui-tui]
|
||||
related_skills: [systematic-debugging, python-debugpy, debugging-hermes-tui-commands]
|
||||
---
|
||||
|
||||
# Node.js Inspect Debugger
|
||||
|
||||
## Overview
|
||||
|
||||
When `console.log` isn't enough, drive Node's built-in V8 inspector programmatically from the terminal. You get real breakpoints, step in/over/out, call-stack walking, local/closure scope dumps, and arbitrary expression evaluation in the paused frame.
|
||||
|
||||
Two tools, pick one:
|
||||
|
||||
- **`node inspect`** — built-in, zero install, CLI REPL. Best for quick poking.
|
||||
- **`ndb` / CDP via `chrome-remote-interface`** — scriptable from Node/Python; best when you want to automate many breakpoints, collect state across runs, or debug non-interactively from an agent loop.
|
||||
|
||||
**Prefer `node inspect` first.** It's always available and the REPL is fast.
|
||||
|
||||
## When to Use
|
||||
|
||||
- A Node test fails and you need to see intermediate state
|
||||
- ui-tui crashes or behaves wrong and you want to inspect React/Ink state pre-render
|
||||
- tui_gateway child processes (`_SlashWorker`, PTY bridge workers) misbehave
|
||||
- You need to inspect a value in a closure that `console.log` can't reach without patching
|
||||
- Perf: attach to a running process to capture a CPU profile or heap snapshot
|
||||
|
||||
**Don't use for:** things `console.log` solves in under a minute. Breakpoint-driven debugging is heavier; use it when the payoff is real.
|
||||
|
||||
## Quick Reference: `node inspect` REPL
|
||||
|
||||
Launch paused on first line:
|
||||
|
||||
```bash
|
||||
node inspect path/to/script.js
|
||||
# or with tsx
|
||||
node --inspect-brk $(which tsx) path/to/script.ts
|
||||
```
|
||||
|
||||
The `debug>` prompt accepts:
|
||||
|
||||
| Command | Action |
|
||||
|---|---|
|
||||
| `c` or `cont` | continue |
|
||||
| `n` or `next` | step over |
|
||||
| `s` or `step` | step into |
|
||||
| `o` or `out` | step out |
|
||||
| `pause` | pause running code |
|
||||
| `sb('file.js', 42)` | set breakpoint at file.js line 42 |
|
||||
| `sb(42)` | set breakpoint at line 42 of current file |
|
||||
| `sb('functionName')` | break when function is called |
|
||||
| `cb('file.js', 42)` | clear breakpoint |
|
||||
| `breakpoints` | list all breakpoints |
|
||||
| `bt` | backtrace (call stack) |
|
||||
| `list(5)` | show 5 lines of source around current position |
|
||||
| `watch('expr')` | evaluate expr on every pause |
|
||||
| `watchers` | show watched expressions |
|
||||
| `repl` | drop into REPL in current scope (Ctrl+C to exit REPL) |
|
||||
| `exec expr` | evaluate expression once |
|
||||
| `restart` | restart script |
|
||||
| `kill` | kill the script |
|
||||
| `.exit` | quit debugger |
|
||||
|
||||
**In the `repl` sub-mode:** type any JS expression, including access to locals/closure variables. `Ctrl+C` exits back to `debug>`.
|
||||
|
||||
## Attaching to a Running Process
|
||||
|
||||
When the process is already running (e.g. a long-lived dev server or the TUI gateway):
|
||||
|
||||
```bash
|
||||
# 1. Send SIGUSR1 to enable the inspector on an existing process
|
||||
kill -SIGUSR1 <pid>
|
||||
# Node prints: Debugger listening on ws://127.0.0.1:9229/<uuid>
|
||||
|
||||
# 2. Attach the debugger CLI
|
||||
node inspect -p <pid>
|
||||
# or by URL
|
||||
node inspect ws://127.0.0.1:9229/<uuid>
|
||||
```
|
||||
|
||||
To start a process with the inspector from the beginning:
|
||||
|
||||
```bash
|
||||
node --inspect script.js # listen on 127.0.0.1:9229, keep running
|
||||
node --inspect-brk script.js # listen AND pause on first line
|
||||
node --inspect=0.0.0.0:9230 script.js # custom host:port
|
||||
```
|
||||
|
||||
For TypeScript via tsx:
|
||||
|
||||
```bash
|
||||
node --inspect-brk --import tsx script.ts
|
||||
# or older tsx
|
||||
node --inspect-brk -r tsx/cjs script.ts
|
||||
```
|
||||
|
||||
## Programmatic CDP (scripting from terminal)
|
||||
|
||||
When you want to automate — set many breakpoints, capture scope state, script a repro — use `chrome-remote-interface`:
|
||||
|
||||
```bash
|
||||
npm i -g chrome-remote-interface # or project-local
|
||||
# Start your target:
|
||||
node --inspect-brk=9229 target.js &
|
||||
```
|
||||
|
||||
Driver script (save as `/tmp/cdp-debug.js`):
|
||||
|
||||
```javascript
|
||||
const CDP = require('chrome-remote-interface');
|
||||
|
||||
(async () => {
|
||||
const client = await CDP({ port: 9229 });
|
||||
const { Debugger, Runtime } = client;
|
||||
|
||||
Debugger.paused(async ({ callFrames, reason }) => {
|
||||
const top = callFrames[0];
|
||||
console.log(`PAUSED: ${reason} @ ${top.url}:${top.location.lineNumber + 1}`);
|
||||
|
||||
// Walk scopes for locals
|
||||
for (const scope of top.scopeChain) {
|
||||
if (scope.type === 'local' || scope.type === 'closure') {
|
||||
const { result } = await Runtime.getProperties({
|
||||
objectId: scope.object.objectId,
|
||||
ownProperties: true,
|
||||
});
|
||||
for (const p of result) {
|
||||
console.log(` ${scope.type}.${p.name} =`, p.value?.value ?? p.value?.description);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate an expression in the paused frame
|
||||
const { result } = await Debugger.evaluateOnCallFrame({
|
||||
callFrameId: top.callFrameId,
|
||||
expression: 'typeof state !== "undefined" ? JSON.stringify(state) : "n/a"',
|
||||
});
|
||||
console.log('state =', result.value ?? result.description);
|
||||
|
||||
await Debugger.resume();
|
||||
});
|
||||
|
||||
await Runtime.enable();
|
||||
await Debugger.enable();
|
||||
|
||||
// Set a breakpoint by URL regex + line
|
||||
await Debugger.setBreakpointByUrl({
|
||||
urlRegex: '.*app\\.tsx$',
|
||||
lineNumber: 119, // 0-indexed
|
||||
columnNumber: 0,
|
||||
});
|
||||
|
||||
await Runtime.runIfWaitingForDebugger();
|
||||
})();
|
||||
```
|
||||
|
||||
Run it:
|
||||
|
||||
```bash
|
||||
node /tmp/cdp-debug.js
|
||||
```
|
||||
|
||||
Hermes-specific note: `chrome-remote-interface` is NOT in `ui-tui/package.json`. Install it to a throwaway location if you don't want to dirty the project:
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/cdp-tools && cd /tmp/cdp-tools && npm i chrome-remote-interface
|
||||
NODE_PATH=/tmp/cdp-tools/node_modules node /tmp/cdp-debug.js
|
||||
```
|
||||
|
||||
## Debugging Hermes ui-tui
|
||||
|
||||
The TUI is built Ink + tsx. Two common scenarios:
|
||||
|
||||
### Debugging a single Ink component under dev
|
||||
|
||||
`ui-tui/package.json` has `npm run dev` (tsx --watch). Add `--inspect-brk` by running tsx directly:
|
||||
|
||||
```bash
|
||||
cd /home/bb/hermes-agent/ui-tui
|
||||
npm run build # produce dist/ once so transpile isn't needed on first load
|
||||
node --inspect-brk dist/entry.js
|
||||
# In another terminal:
|
||||
node inspect -p <node pid>
|
||||
```
|
||||
|
||||
Then inside `debug>`:
|
||||
|
||||
```
|
||||
sb('dist/app.js', 220) # or wherever the suspect render is
|
||||
cont
|
||||
```
|
||||
|
||||
When it pauses, `repl` → inspect `props`, state refs, `useInput` handler values, etc.
|
||||
|
||||
### Debugging a running `hermes --tui`
|
||||
|
||||
The TUI spawns Node from the Python CLI. Easiest path:
|
||||
|
||||
```bash
|
||||
# 1. Launch TUI
|
||||
hermes --tui &
|
||||
TUI_PID=$(pgrep -f 'ui-tui/dist/entry' | head -1)
|
||||
|
||||
# 2. Enable inspector on that Node PID
|
||||
kill -SIGUSR1 "$TUI_PID"
|
||||
|
||||
# 3. Find the WS URL
|
||||
curl -s http://127.0.0.1:9229/json/list | jq -r '.[0].webSocketDebuggerUrl'
|
||||
|
||||
# 4. Attach
|
||||
node inspect ws://127.0.0.1:9229/<uuid>
|
||||
```
|
||||
|
||||
Interacting with the TUI (typing in its window) continues to advance execution; your debugger can pause it on a breakpoint at any `sb(...)`.
|
||||
|
||||
### Debugging `_SlashWorker` / PTY child processes
|
||||
|
||||
Those are Python, not Node — use the `python-debugpy` skill for them. Only Node portions (Ink UI, tui_gateway client, tsx-run tests under `ui-tui/`) use this skill.
|
||||
|
||||
## Running Vitest Tests Under the Debugger
|
||||
|
||||
```bash
|
||||
cd /home/bb/hermes-agent/ui-tui
|
||||
# Run a single test file paused on entry
|
||||
node --inspect-brk ./node_modules/vitest/vitest.mjs run --no-file-parallelism src/app/foo.test.tsx
|
||||
```
|
||||
|
||||
In another terminal: `node inspect -p <pid>`, then `sb('src/app/foo.tsx', 42)`, `cont`.
|
||||
|
||||
Use `--no-file-parallelism` (vitest) or `--runInBand` (jest) so only one worker exists — debugging a pool is painful.
|
||||
|
||||
## Heap Snapshots & CPU Profiles (Non-interactive)
|
||||
|
||||
From the CDP driver above, swap Debugger for `HeapProfiler` / `Profiler`:
|
||||
|
||||
```javascript
|
||||
// CPU profile for 5 seconds
|
||||
await client.Profiler.enable();
|
||||
await client.Profiler.start();
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
const { profile } = await client.Profiler.stop();
|
||||
require('fs').writeFileSync('/tmp/cpu.cpuprofile', JSON.stringify(profile));
|
||||
// Open /tmp/cpu.cpuprofile in Chrome DevTools → Performance tab
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Heap snapshot
|
||||
await client.HeapProfiler.enable();
|
||||
const chunks = [];
|
||||
client.HeapProfiler.addHeapSnapshotChunk(({ chunk }) => chunks.push(chunk));
|
||||
await client.HeapProfiler.takeHeapSnapshot({ reportProgress: false });
|
||||
require('fs').writeFileSync('/tmp/heap.heapsnapshot', chunks.join(''));
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Wrong line numbers in TS source.** Breakpoints hit the emitted JS, not the `.ts`. Either (a) break in the built `dist/*.js`, or (b) enable sourcemaps (`node --enable-source-maps`) and use `sb('src/app.tsx', N)` — but only with CDP clients that follow sourcemaps. `node inspect` CLI does not.
|
||||
|
||||
2. **`--inspect` vs `--inspect-brk`.** `--inspect` starts the inspector but doesn't pause; your script races past your first breakpoint if you attach too late. Use `--inspect-brk` when you need to set breakpoints before any code runs.
|
||||
|
||||
3. **Port collisions.** Default is `9229`. If multiple Node processes are inspecting, pass `--inspect=0` (random port) and read the actual URL from `/json/list`:
|
||||
```bash
|
||||
curl -s http://127.0.0.1:9229/json/list # lists all inspectable targets on the host
|
||||
```
|
||||
|
||||
4. **Child processes.** `--inspect` on a parent does NOT inspect its children. Use `NODE_OPTIONS='--inspect-brk' node parent.js` to propagate to every child; be aware they all need unique ports (Node auto-increments when `NODE_OPTIONS='--inspect'` is inherited).
|
||||
|
||||
5. **Background kills.** If you `Ctrl+C` out of `node inspect` while the target is paused, the target stays paused. Either `cont` first, or `kill` the target explicitly.
|
||||
|
||||
6. **Running `node inspect` through an agent terminal.** It's a PTY-friendly REPL. In Hermes, launch it with `terminal(pty=true)` or `background=true` + `process(action='submit', data='...')`. Non-PTY foreground mode will work for one-shot commands but not for interactive stepping.
|
||||
|
||||
7. **Security.** `--inspect=0.0.0.0:9229` exposes arbitrary code execution. Always bind to `127.0.0.1` (the default) unless you have an isolated network.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After setting up a debug session, verify:
|
||||
|
||||
- [ ] `curl -s http://127.0.0.1:9229/json/list` returns exactly the target you expect
|
||||
- [ ] First breakpoint actually hits (if it doesn't, you likely missed `--inspect-brk` or attached after execution completed)
|
||||
- [ ] Source listing at pause shows the right file (mismatch = sourcemap issue, see pitfall 1)
|
||||
- [ ] `exec process.pid` in `repl` returns the PID you meant to attach to
|
||||
|
||||
## One-Shot Recipes
|
||||
|
||||
**"Why is this variable undefined at line X?"**
|
||||
```bash
|
||||
node --inspect-brk script.js &
|
||||
node inspect -p $!
|
||||
# debug>
|
||||
sb('script.js', X)
|
||||
cont
|
||||
# paused. Now:
|
||||
repl
|
||||
> myVariable
|
||||
> Object.keys(this)
|
||||
```
|
||||
|
||||
**"What's the call path into this function?"**
|
||||
```
|
||||
debug> sb('suspectFn')
|
||||
debug> cont
|
||||
# paused on entry
|
||||
debug> bt
|
||||
```
|
||||
|
||||
**"This async chain hangs — where?"**
|
||||
```
|
||||
# Start with --inspect (no -brk), let it run to the hang, then:
|
||||
debug> pause
|
||||
debug> bt
|
||||
# Now you see the stuck frame
|
||||
```
|
||||
@@ -0,0 +1,374 @@
|
||||
---
|
||||
name: python-debugpy
|
||||
description: Use when debugging Python code (run_agent.py, cli.py, tui_gateway, tests, scripts) with real breakpoints, stepping, scope inspection, and post-mortem analysis. Covers `pdb` for interactive REPL debugging and `debugpy` for remote/headless DAP-driven sessions.
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [debugging, python, pdb, debugpy, breakpoints, dap, post-mortem]
|
||||
related_skills: [systematic-debugging, node-inspect-debugger, debugging-hermes-tui-commands]
|
||||
---
|
||||
|
||||
# Python Debugger (pdb + debugpy)
|
||||
|
||||
## Overview
|
||||
|
||||
Three tools, picked by situation:
|
||||
|
||||
| Tool | When |
|
||||
|---|---|
|
||||
| **`breakpoint()` + pdb** | Local, interactive, simplest. Add `breakpoint()` in the source, run normally, get a REPL at that line. |
|
||||
| **`python -m pdb`** | Launch an existing script under pdb with no source edits. Useful for quick poking. |
|
||||
| **`debugpy`** | Remote / headless / "attach to already-running process." Talks DAP, scriptable from terminal, works for long-lived processes (gateway, daemon, PTY children). |
|
||||
|
||||
**Start with `breakpoint()`.** It's the cheapest thing that works.
|
||||
|
||||
## When to Use
|
||||
|
||||
- A test fails and the traceback doesn't reveal why a value is wrong
|
||||
- You need to step through a function and watch a collection mutate
|
||||
- A long-running process (hermes gateway, tui_gateway) misbehaves and you can't restart it
|
||||
- Post-mortem: an exception fired in prod-ish code and you want to inspect locals at the crash site
|
||||
- A subprocess / child (Python `_SlashWorker`, PTY bridge worker) is the actual bug site
|
||||
|
||||
**Don't use for:** things `print()` / `logging.debug` solve in under a minute, or things `pytest -vv --tb=long --showlocals` already reveals.
|
||||
|
||||
## pdb Quick Reference
|
||||
|
||||
Inside any pdb prompt (`(Pdb)`):
|
||||
|
||||
| Command | Action |
|
||||
|---|---|
|
||||
| `h` / `h cmd` | help |
|
||||
| `n` | next line (step over) |
|
||||
| `s` | step into |
|
||||
| `r` | return from current function |
|
||||
| `c` | continue |
|
||||
| `unt N` | continue until line N |
|
||||
| `j N` | jump to line N (same function only) |
|
||||
| `l` / `ll` | list source around current line / full function |
|
||||
| `w` | where (stack trace) |
|
||||
| `u` / `d` | move up / down in the stack |
|
||||
| `a` | print args of the current function |
|
||||
| `p expr` / `pp expr` | print / pretty-print expression |
|
||||
| `display expr` | auto-print expr on every stop |
|
||||
| `b file:line` | set breakpoint |
|
||||
| `b func` | break on function entry |
|
||||
| `b file:line, cond` | conditional breakpoint |
|
||||
| `cl N` | clear breakpoint N |
|
||||
| `tbreak file:line` | one-shot breakpoint |
|
||||
| `!stmt` | execute arbitrary Python (assignments included) |
|
||||
| `interact` | drop into full Python REPL in current scope (Ctrl+D to exit) |
|
||||
| `q` | quit |
|
||||
|
||||
The `interact` command is the most powerful — you can import anything, inspect complex objects, even call methods that mutate state. Locals are read-only by default; use `!x = 42` from the `(Pdb)` prompt to mutate.
|
||||
|
||||
## Recipe 1: Local breakpoint
|
||||
|
||||
Easiest. Edit the file:
|
||||
|
||||
```python
|
||||
def compute(x, y):
|
||||
result = some_helper(x)
|
||||
breakpoint() # <-- drops into pdb here
|
||||
return result + y
|
||||
```
|
||||
|
||||
Run the code normally. You land at the `breakpoint()` line with full access to locals.
|
||||
|
||||
**Don't forget to remove `breakpoint()` before committing.** Use `git diff` or a pre-commit grep:
|
||||
```bash
|
||||
rg -n 'breakpoint\(\)' --type py
|
||||
```
|
||||
|
||||
## Recipe 2: Launch a script under pdb (no source edits)
|
||||
|
||||
```bash
|
||||
python -m pdb path/to/script.py arg1 arg2
|
||||
# Lands at first line of script
|
||||
(Pdb) b path/to/script.py:42
|
||||
(Pdb) c
|
||||
```
|
||||
|
||||
## Recipe 3: Debug a pytest test
|
||||
|
||||
The hermes test runner and pytest both support this:
|
||||
|
||||
```bash
|
||||
# Drop to pdb on failure (or on any raised exception):
|
||||
scripts/run_tests.sh tests/path/to/test_file.py::test_name --pdb
|
||||
|
||||
# Drop to pdb at the START of the test:
|
||||
scripts/run_tests.sh tests/path/to/test_file.py::test_name --trace
|
||||
|
||||
# Show locals in tracebacks without pdb:
|
||||
scripts/run_tests.sh tests/path/to/test_file.py --showlocals --tb=long
|
||||
```
|
||||
|
||||
Note: `scripts/run_tests.sh` uses xdist (`-n 4`) by default, and pdb does NOT work under xdist. Add `-p no:xdist` or run a single test with `-n 0`:
|
||||
|
||||
```bash
|
||||
scripts/run_tests.sh tests/foo_test.py::test_bar --pdb -p no:xdist
|
||||
# or
|
||||
source .venv/bin/activate
|
||||
python -m pytest tests/foo_test.py::test_bar --pdb
|
||||
```
|
||||
|
||||
This bypasses the hermetic-env guarantees — fine for debugging, but re-run under the wrapper to confirm before pushing.
|
||||
|
||||
## Recipe 4: Post-mortem on any exception
|
||||
|
||||
```python
|
||||
import pdb, sys
|
||||
try:
|
||||
run_the_thing()
|
||||
except Exception:
|
||||
pdb.post_mortem(sys.exc_info()[2])
|
||||
```
|
||||
|
||||
Or wrap a whole script:
|
||||
|
||||
```bash
|
||||
python -m pdb -c continue script.py
|
||||
# When it crashes, pdb catches it and you're in the frame of the exception
|
||||
```
|
||||
|
||||
Or set a global hook in a repl/jupyter:
|
||||
|
||||
```python
|
||||
import sys
|
||||
def excepthook(etype, value, tb):
|
||||
import pdb; pdb.post_mortem(tb)
|
||||
sys.excepthook = excepthook
|
||||
```
|
||||
|
||||
## Recipe 5: Remote debug with debugpy (attach to running process)
|
||||
|
||||
For long-lived processes: Hermes gateway, tui_gateway, a daemon, a process that's already misbehaving and can't be restarted clean.
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
source /home/bb/hermes-agent/.venv/bin/activate
|
||||
pip install debugpy
|
||||
```
|
||||
|
||||
### Pattern A: Source-edit — process waits for debugger at launch
|
||||
|
||||
Add near the top of the entry point (or inside the function you want to debug):
|
||||
|
||||
```python
|
||||
import debugpy
|
||||
debugpy.listen(("127.0.0.1", 5678))
|
||||
print("debugpy listening on 5678, waiting for client...", flush=True)
|
||||
debugpy.wait_for_client()
|
||||
debugpy.breakpoint() # optional: pause immediately once attached
|
||||
```
|
||||
|
||||
Start the process; it blocks on `wait_for_client()`.
|
||||
|
||||
### Pattern B: No source edit — launch with `-m debugpy`
|
||||
|
||||
```bash
|
||||
python -m debugpy --listen 127.0.0.1:5678 --wait-for-client your_script.py arg1
|
||||
```
|
||||
|
||||
Equivalent for module entry:
|
||||
|
||||
```bash
|
||||
python -m debugpy --listen 127.0.0.1:5678 --wait-for-client -m your.module
|
||||
```
|
||||
|
||||
### Pattern C: Attach to an already-running process
|
||||
|
||||
Needs the PID and debugpy preinstalled in the target's environment:
|
||||
|
||||
```bash
|
||||
python -m debugpy --listen 127.0.0.1:5678 --pid <pid>
|
||||
# debugpy injects itself into the process. Then attach a client as below.
|
||||
```
|
||||
|
||||
Some kernels/security configs block the ptrace-based injection (`/proc/sys/kernel/yama/ptrace_scope`). Fix with:
|
||||
```bash
|
||||
echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
|
||||
```
|
||||
|
||||
### Connecting a client from the terminal
|
||||
|
||||
The easiest terminal-side DAP client is VS Code CLI or a small script. From inside Hermes you have two practical options:
|
||||
|
||||
**Option 1: `debugpy`'s own CLI REPL** — not an official feature, but a tiny DAP client script:
|
||||
|
||||
```python
|
||||
# /tmp/dap_client.py
|
||||
import socket, json, itertools, time, sys
|
||||
|
||||
HOST, PORT = "127.0.0.1", 5678
|
||||
s = socket.create_connection((HOST, PORT))
|
||||
seq = itertools.count(1)
|
||||
|
||||
def send(msg):
|
||||
msg["seq"] = next(seq)
|
||||
body = json.dumps(msg).encode()
|
||||
s.sendall(f"Content-Length: {len(body)}\r\n\r\n".encode() + body)
|
||||
|
||||
def recv():
|
||||
header = b""
|
||||
while b"\r\n\r\n" not in header:
|
||||
header += s.recv(1)
|
||||
length = int(header.decode().split("Content-Length:")[1].split("\r\n")[0].strip())
|
||||
body = b""
|
||||
while len(body) < length:
|
||||
body += s.recv(length - len(body))
|
||||
return json.loads(body)
|
||||
|
||||
send({"type": "request", "command": "initialize", "arguments": {"adapterID": "python"}})
|
||||
print(recv())
|
||||
send({"type": "request", "command": "attach", "arguments": {}})
|
||||
print(recv())
|
||||
send({"type": "request", "command": "setBreakpoints",
|
||||
"arguments": {"source": {"path": sys.argv[1]},
|
||||
"breakpoints": [{"line": int(sys.argv[2])}]}})
|
||||
print(recv())
|
||||
send({"type": "request", "command": "configurationDone"})
|
||||
# ... loop reading events and sending continue/stepIn/etc.
|
||||
```
|
||||
|
||||
This is fine for one-off automation but painful as an interactive UX.
|
||||
|
||||
**Option 2: Attach from VS Code / Cursor / Zed** — if the user has one open, they can add a `launch.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Attach to Hermes",
|
||||
"type": "debugpy",
|
||||
"request": "attach",
|
||||
"connect": { "host": "127.0.0.1", "port": 5678 },
|
||||
"justMyCode": false,
|
||||
"pathMappings": [
|
||||
{ "localRoot": "${workspaceFolder}", "remoteRoot": "/home/bb/hermes-agent" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Option 3: Ditch DAP, use `remote-pdb`** — usually what you actually want from a terminal agent:
|
||||
|
||||
```bash
|
||||
pip install remote-pdb
|
||||
```
|
||||
|
||||
In your code:
|
||||
```python
|
||||
from remote_pdb import set_trace
|
||||
set_trace(host="127.0.0.1", port=4444) # blocks until connection
|
||||
```
|
||||
|
||||
Then from the terminal:
|
||||
```bash
|
||||
nc 127.0.0.1 4444
|
||||
# You get a (Pdb) prompt exactly as if debugging locally.
|
||||
```
|
||||
|
||||
`remote-pdb` is the cleanest agent-friendly choice when `debugpy`'s DAP protocol is overkill. Use `debugpy` only when you actually need IDE integration.
|
||||
|
||||
## Debugging Hermes-specific Processes
|
||||
|
||||
### Tests
|
||||
See Recipe 3. Always add `-p no:xdist` or run single tests without xdist.
|
||||
|
||||
### `run_agent.py` / CLI — one-shot
|
||||
Easiest: add `breakpoint()` near the suspect line, then run `hermes` normally. Control returns to your terminal at the pause point.
|
||||
|
||||
### `tui_gateway` subprocess (spawned by `hermes --tui`)
|
||||
The gateway runs as a child of the Node TUI. Options:
|
||||
|
||||
**A. Source-edit the gateway:**
|
||||
```python
|
||||
# tui_gateway/server.py near the top of serve()
|
||||
import debugpy
|
||||
debugpy.listen(("127.0.0.1", 5678))
|
||||
debugpy.wait_for_client()
|
||||
```
|
||||
Start `hermes --tui`. The TUI will appear frozen (its backend is waiting). Attach a client; execution resumes when you `continue`.
|
||||
|
||||
**B. Use `remote-pdb` at a specific handler:**
|
||||
```python
|
||||
from remote_pdb import set_trace
|
||||
set_trace(host="127.0.0.1", port=4444) # in the RPC handler you want to trap
|
||||
```
|
||||
Trigger the matching slash command from the TUI, then `nc 127.0.0.1 4444` in another terminal.
|
||||
|
||||
### `_SlashWorker` subprocess
|
||||
Same pattern — `remote-pdb` with `set_trace()` inside the worker's `exec` path. The worker is persistent across slash commands, so the first trigger blocks until you connect; subsequent slash commands pass through normally unless you re-arm.
|
||||
|
||||
### Gateway (`gateway/run.py`)
|
||||
Long-lived. Use `remote-pdb` at a handler, or `debugpy` with `--wait-for-client` if you're restarting the gateway anyway.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **pdb under pytest-xdist silently does nothing.** You won't see the prompt, the test just hangs. Always use `-p no:xdist` or `-n 0`.
|
||||
|
||||
2. **`breakpoint()` in CI / non-TTY contexts hangs the process.** Safe locally; never commit it. Add a pre-commit grep as a safety net.
|
||||
|
||||
3. **`PYTHONBREAKPOINT=0`** disables all `breakpoint()` calls. Check the env if your breakpoint isn't hitting:
|
||||
```bash
|
||||
echo $PYTHONBREAKPOINT
|
||||
```
|
||||
|
||||
4. **`debugpy.listen` blocks only if you also call `wait_for_client()`.** Without it, execution continues and your first breakpoint may fire before the client is attached.
|
||||
|
||||
5. **Attach to PID fails on hardened kernels.** `ptrace_scope=1` (Ubuntu default) allows only same-user ptrace of child processes. Workaround: `echo 0 > /proc/sys/kernel/yama/ptrace_scope` (needs root) or launch under `debugpy` from the start.
|
||||
|
||||
6. **Threads.** `pdb` only debugs the current thread. For multithreaded code, use `debugpy` (thread-aware DAP) or set `threading.settrace()` per thread.
|
||||
|
||||
7. **asyncio.** `pdb` works in coroutines but `await` inside pdb requires Python 3.13+ or `await` from `interact` mode on older versions. For 3.11/3.12, use `asyncio.run_coroutine_threadsafe` tricks or `!stmt`-based awaits via `asyncio.ensure_future`.
|
||||
|
||||
8. **`scripts/run_tests.sh` strips credentials and sets `HOME=<tmpdir>`.** If your bug depends on user config or real API keys, it won't reproduce under the wrapper. Debug with raw `pytest` first to repro, then re-confirm under the wrapper.
|
||||
|
||||
9. **Forking / multiprocessing.** pdb does not follow forks. Each child needs its own `breakpoint()` or `set_trace()`. For Hermes subagents, debug one process at a time.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] After `pip install debugpy`, confirm: `python -c "import debugpy; print(debugpy.__version__)"`
|
||||
- [ ] For remote debug, confirm the port is actually listening: `ss -tlnp | grep 5678`
|
||||
- [ ] First breakpoint actually hits (if it doesn't, you likely have `PYTHONBREAKPOINT=0`, you're under xdist, or execution finished before attach)
|
||||
- [ ] `where` / `w` shows the expected call stack
|
||||
- [ ] Post-debug cleanup: no stray `breakpoint()` / `set_trace()` in committed code
|
||||
```bash
|
||||
rg -n 'breakpoint\(\)|set_trace\(|debugpy\.listen' --type py
|
||||
```
|
||||
|
||||
## One-Shot Recipes
|
||||
|
||||
**"Why is this dict missing a key?"**
|
||||
```python
|
||||
# add above the KeyError site
|
||||
breakpoint()
|
||||
# then in pdb:
|
||||
(Pdb) pp d
|
||||
(Pdb) pp list(d.keys())
|
||||
(Pdb) w # how did we get here
|
||||
```
|
||||
|
||||
**"This test passes in isolation but fails in the suite."**
|
||||
```bash
|
||||
scripts/run_tests.sh tests/the_test.py --pdb -p no:xdist
|
||||
# But if it only fails WITH other tests:
|
||||
source .venv/bin/activate
|
||||
python -m pytest tests/ -x --pdb -p no:xdist
|
||||
# Now it pdb-traps at the exact failing test after state accumulated.
|
||||
```
|
||||
|
||||
**"My async handler deadlocks."**
|
||||
```python
|
||||
# Add at handler entry
|
||||
import remote_pdb; remote_pdb.set_trace(host="127.0.0.1", port=4444)
|
||||
```
|
||||
Trigger the handler. `nc 127.0.0.1 4444`, then `w` to see the suspended frame, `!import asyncio; asyncio.all_tasks()` to see what else is pending.
|
||||
|
||||
**"Post-mortem on a crash in an Ink child process / subprocess."**
|
||||
```bash
|
||||
PYTHONFAULTHANDLER=1 python -m pdb -c continue path/to/entrypoint.py
|
||||
# On crash, pdb lands at the frame of the exception with full locals
|
||||
```
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: yuanbao
|
||||
description: Yuanbao (元宝) group interaction — @mention users, query group info and members
|
||||
version: 1.0.0
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [yuanbao, mention, at, group, members, 元宝, 派, 艾特]
|
||||
related_skills: []
|
||||
---
|
||||
|
||||
# Yuanbao Group Interaction
|
||||
|
||||
## CRITICAL: How Messaging Works
|
||||
|
||||
**Your text reply IS the message sent to the group/user.** The gateway automatically delivers your response text to the chat. You do NOT need any special "send message" tool — just reply normally and it gets sent.
|
||||
|
||||
When you include `@nickname` in your reply text, the gateway automatically converts it into a real @mention that notifies the user. This is built-in — you have full @mention capability.
|
||||
|
||||
**NEVER say you cannot send messages or @mention users. NEVER suggest the user do it manually. NEVER add disclaimers about permissions. Just reply with the text you want sent.**
|
||||
|
||||
## Available Tools
|
||||
|
||||
| Tool | When to use |
|
||||
|------|------------|
|
||||
| `yb_query_group_info` | Query group name, owner, member count |
|
||||
| `yb_query_group_members` | Find a user, list bots, list all members, or get nickname for @mention |
|
||||
| `yb_send_dm` | Send a private/direct message (DM / 私信) to a user, with optional media files |
|
||||
|
||||
## @Mention Workflow
|
||||
|
||||
When you need to @mention / 艾特 someone:
|
||||
|
||||
1. Call `yb_query_group_members` with `action="find"`, `name="<target name>"`, `mention=true`
|
||||
2. Get the exact nickname from the response
|
||||
3. Include `@nickname` in your reply text — the gateway handles the rest
|
||||
|
||||
Example: user says "帮我艾特元宝"
|
||||
|
||||
Step 1 — tool call:
|
||||
```json
|
||||
{ "group_code": "328306697", "action": "find", "name": "元宝", "mention": true }
|
||||
```
|
||||
|
||||
Step 2 — your reply (this gets sent to the group with a working @mention):
|
||||
```
|
||||
@元宝 你好,有人找你!
|
||||
```
|
||||
|
||||
**That's it.** No extra explanation needed. Keep it short and natural.
|
||||
|
||||
**Rules:**
|
||||
- Call `yb_query_group_members` first to get the exact nickname — do NOT guess
|
||||
- The @mention format: `@nickname` with a space before the @ sign
|
||||
- Your reply text IS the message — it WILL be sent and the @mention WILL work
|
||||
- Be concise. Do NOT explain how @mention works to the user.
|
||||
|
||||
## Send DM (Private Message) Workflow
|
||||
|
||||
When someone asks to send a private message / 私信 / DM to a user:
|
||||
|
||||
1. Call `yb_send_dm` with `group_code`, `name` (target user's name), and `message`
|
||||
2. The tool automatically finds the user and sends the DM
|
||||
3. Report the result to the user
|
||||
|
||||
Example: user says "给 @用户aea3 私信发一个 hello"
|
||||
|
||||
```json
|
||||
yb_send_dm({ "group_code": "535168412", "name": "用户aea3", "message": "hello" })
|
||||
```
|
||||
|
||||
Example with media: user says "给 @用户aea3 私信发一张图片"
|
||||
|
||||
```json
|
||||
yb_send_dm({
|
||||
"group_code": "535168412",
|
||||
"name": "用户aea3",
|
||||
"message": "Here is the image",
|
||||
"media_files": [{"path": "/tmp/photo.jpg"}]
|
||||
})
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Extract `group_code` from the current chat_id (e.g. `group:535168412` → `535168412`)
|
||||
- If you already know the user_id, pass it directly via the `user_id` parameter to skip lookup
|
||||
- If multiple users match the name, the tool returns candidates — ask the user to clarify
|
||||
- Do NOT use `send_message` tool for Yuanbao DMs — use `yb_send_dm` instead
|
||||
- Supports media: images (.jpg/.png/.gif/.webp/.bmp) sent as image messages, other files as documents
|
||||
|
||||
## Query Group Info
|
||||
|
||||
```json
|
||||
yb_query_group_info({ "group_code": "328306697" })
|
||||
```
|
||||
|
||||
## Query Members
|
||||
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| `find` | Search by name (partial match, case-insensitive) |
|
||||
| `list_bots` | List bots and Yuanbao AI assistants |
|
||||
| `list_all` | List all members |
|
||||
|
||||
## Notes
|
||||
|
||||
- `group_code` comes from chat_id: `group:328306697` → `328306697`
|
||||
- Groups are called "派 (Pai)" in the Yuanbao app
|
||||
- Member roles: `user`, `yuanbao_ai`, `bot`
|
||||
@@ -7,11 +7,14 @@ import pytest
|
||||
|
||||
from agent.onboarding import (
|
||||
BUSY_INPUT_FLAG,
|
||||
OPENCLAW_RESIDUE_FLAG,
|
||||
TOOL_PROGRESS_FLAG,
|
||||
busy_input_hint_cli,
|
||||
busy_input_hint_gateway,
|
||||
detect_openclaw_residue,
|
||||
is_seen,
|
||||
mark_seen,
|
||||
openclaw_residue_hint_cli,
|
||||
tool_progress_hint_cli,
|
||||
tool_progress_hint_gateway,
|
||||
)
|
||||
@@ -117,6 +120,12 @@ class TestHintMessages:
|
||||
assert "/busy interrupt" in msg
|
||||
assert "queued" in msg.lower()
|
||||
|
||||
def test_busy_input_hint_gateway_steer(self):
|
||||
msg = busy_input_hint_gateway("steer")
|
||||
assert "/busy interrupt" in msg
|
||||
assert "/busy queue" in msg
|
||||
assert "steer" in msg.lower()
|
||||
|
||||
def test_busy_input_hint_cli_interrupt(self):
|
||||
msg = busy_input_hint_cli("interrupt")
|
||||
assert "/busy queue" in msg
|
||||
@@ -125,6 +134,12 @@ class TestHintMessages:
|
||||
msg = busy_input_hint_cli("queue")
|
||||
assert "/busy interrupt" in msg
|
||||
|
||||
def test_busy_input_hint_cli_steer(self):
|
||||
msg = busy_input_hint_cli("steer")
|
||||
assert "/busy interrupt" in msg
|
||||
assert "/busy queue" in msg
|
||||
assert "steer" in msg.lower()
|
||||
|
||||
def test_tool_progress_hints_mention_verbose(self):
|
||||
assert "/verbose" in tool_progress_hint_gateway()
|
||||
assert "/verbose" in tool_progress_hint_cli()
|
||||
@@ -133,8 +148,10 @@ class TestHintMessages:
|
||||
for hint in (
|
||||
busy_input_hint_gateway("queue"),
|
||||
busy_input_hint_gateway("interrupt"),
|
||||
busy_input_hint_gateway("steer"),
|
||||
busy_input_hint_cli("queue"),
|
||||
busy_input_hint_cli("interrupt"),
|
||||
busy_input_hint_cli("steer"),
|
||||
tool_progress_hint_gateway(),
|
||||
tool_progress_hint_cli(),
|
||||
):
|
||||
@@ -162,3 +179,50 @@ class TestRoundTrip:
|
||||
|
||||
assert is_seen(loaded, BUSY_INPUT_FLAG) is True
|
||||
assert is_seen(loaded, TOOL_PROGRESS_FLAG) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenClaw residue banner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetectOpenclawResidue:
|
||||
def test_returns_true_when_openclaw_dir_present(self, tmp_path):
|
||||
(tmp_path / ".openclaw").mkdir()
|
||||
assert detect_openclaw_residue(home=tmp_path) is True
|
||||
|
||||
def test_returns_false_when_absent(self, tmp_path):
|
||||
assert detect_openclaw_residue(home=tmp_path) is False
|
||||
|
||||
def test_returns_false_when_path_is_a_file(self, tmp_path):
|
||||
# A stray file named ``.openclaw`` is NOT a workspace — skip the banner.
|
||||
(tmp_path / ".openclaw").write_text("oops")
|
||||
assert detect_openclaw_residue(home=tmp_path) is False
|
||||
|
||||
def test_default_home_does_not_crash(self):
|
||||
# Smoke: real $HOME lookup must not raise regardless of state.
|
||||
assert isinstance(detect_openclaw_residue(), bool)
|
||||
|
||||
|
||||
class TestOpenclawResidueHint:
|
||||
def test_hint_mentions_cleanup_command(self):
|
||||
msg = openclaw_residue_hint_cli()
|
||||
assert "hermes claw cleanup" in msg
|
||||
assert "~/.openclaw" in msg
|
||||
|
||||
def test_hint_not_empty(self):
|
||||
assert openclaw_residue_hint_cli().strip()
|
||||
|
||||
|
||||
class TestOpenclawResidueSeenFlag:
|
||||
def test_flag_independent_of_other_flags(self, tmp_path):
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
mark_seen(cfg_path, BUSY_INPUT_FLAG)
|
||||
loaded = yaml.safe_load(cfg_path.read_text())
|
||||
assert is_seen(loaded, OPENCLAW_RESIDUE_FLAG) is False
|
||||
|
||||
def test_flag_round_trips(self, tmp_path):
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
assert mark_seen(cfg_path, OPENCLAW_RESIDUE_FLAG) is True
|
||||
loaded = yaml.safe_load(cfg_path.read_text())
|
||||
assert is_seen(loaded, OPENCLAW_RESIDUE_FLAG) is True
|
||||
|
||||
@@ -240,3 +240,74 @@ class TestAllowlistOps:
|
||||
and e.get("command") == str(script)
|
||||
]
|
||||
assert len(matching) == 1
|
||||
|
||||
|
||||
# ── hooks_auto_accept config parsing ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestHooksAutoAcceptParsing:
|
||||
"""Regression guard: YAML-string values must not silently auto-accept.
|
||||
|
||||
``bool("false")`` is ``True`` in Python, so the old ``return bool(cfg_val)``
|
||||
path treated ``hooks_auto_accept: "false"`` (quoted YAML string) as a
|
||||
truthy opt-in, silently bypassing user consent for every shell hook.
|
||||
"""
|
||||
|
||||
def test_bool_true_accepts(self):
|
||||
assert shell_hooks._resolve_effective_accept(
|
||||
{"hooks_auto_accept": True}, accept_hooks_arg=False,
|
||||
) is True
|
||||
|
||||
def test_bool_false_rejects(self):
|
||||
assert shell_hooks._resolve_effective_accept(
|
||||
{"hooks_auto_accept": False}, accept_hooks_arg=False,
|
||||
) is False
|
||||
|
||||
def test_string_false_rejects(self):
|
||||
# The bug: bool("false") is True. Must be parsed, not coerced.
|
||||
assert shell_hooks._resolve_effective_accept(
|
||||
{"hooks_auto_accept": "false"}, accept_hooks_arg=False,
|
||||
) is False
|
||||
|
||||
def test_string_no_rejects(self):
|
||||
assert shell_hooks._resolve_effective_accept(
|
||||
{"hooks_auto_accept": "no"}, accept_hooks_arg=False,
|
||||
) is False
|
||||
|
||||
def test_string_true_accepts(self):
|
||||
assert shell_hooks._resolve_effective_accept(
|
||||
{"hooks_auto_accept": "true"}, accept_hooks_arg=False,
|
||||
) is True
|
||||
|
||||
def test_string_true_case_insensitive(self):
|
||||
assert shell_hooks._resolve_effective_accept(
|
||||
{"hooks_auto_accept": " TRUE "}, accept_hooks_arg=False,
|
||||
) is True
|
||||
|
||||
def test_string_yes_on_one_accept(self):
|
||||
for val in ("yes", "on", "1"):
|
||||
assert shell_hooks._resolve_effective_accept(
|
||||
{"hooks_auto_accept": val}, accept_hooks_arg=False,
|
||||
) is True, val
|
||||
|
||||
def test_missing_key_rejects(self):
|
||||
assert shell_hooks._resolve_effective_accept(
|
||||
{}, accept_hooks_arg=False,
|
||||
) is False
|
||||
|
||||
def test_none_rejects(self):
|
||||
assert shell_hooks._resolve_effective_accept(
|
||||
{"hooks_auto_accept": None}, accept_hooks_arg=False,
|
||||
) is False
|
||||
|
||||
def test_integer_ignored(self):
|
||||
# Only bool and str are honored; anything else (including 1) is False.
|
||||
assert shell_hooks._resolve_effective_accept(
|
||||
{"hooks_auto_accept": 1}, accept_hooks_arg=False,
|
||||
) is False
|
||||
|
||||
def test_cli_arg_overrides_config(self):
|
||||
assert shell_hooks._resolve_effective_accept(
|
||||
{"hooks_auto_accept": "false"}, accept_hooks_arg=True,
|
||||
) is True
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -65,6 +65,35 @@ class TestHandleBusyCommand(unittest.TestCase):
|
||||
self.assertEqual(stub.busy_input_mode, "interrupt")
|
||||
mock_save.assert_called_once_with("display.busy_input_mode", "interrupt")
|
||||
|
||||
def test_steer_argument_sets_steer_mode_and_saves(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli("interrupt")
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint") as mock_cprint,
|
||||
patch.object(cli_mod, "save_config_value", return_value=True) as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_busy_command(stub, "/busy steer")
|
||||
|
||||
self.assertEqual(stub.busy_input_mode, "steer")
|
||||
mock_save.assert_called_once_with("display.busy_input_mode", "steer")
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
self.assertIn("steer", printed.lower())
|
||||
|
||||
def test_status_reports_steer_behavior(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli("steer")
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint") as mock_cprint,
|
||||
patch.object(cli_mod, "save_config_value") as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_busy_command(stub, "/busy status")
|
||||
|
||||
mock_save.assert_not_called()
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
self.assertIn("steer", printed.lower())
|
||||
# The usage line should also advertise the steer option
|
||||
self.assertIn("steer", printed)
|
||||
|
||||
def test_invalid_argument_prints_usage(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli()
|
||||
@@ -90,5 +119,5 @@ class TestBusyCommandRegistry(unittest.TestCase):
|
||||
from hermes_cli.commands import COMMAND_REGISTRY
|
||||
|
||||
busy = next(c for c in COMMAND_REGISTRY if c.name == "busy")
|
||||
assert busy.args_hint == "[queue|interrupt|status]"
|
||||
assert busy.args_hint == "[queue|steer|interrupt|status]"
|
||||
assert busy.category == "Configuration"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Tests for /save — the conversation snapshot slash command.
|
||||
|
||||
Regression: the old implementation wrote ``hermes_conversation_<ts>.json``
|
||||
to the current working directory (CWD). Users who ran /save expected the
|
||||
file to be discoverable via ``hermes sessions browse``, but CWD-resident
|
||||
snapshots are not indexed in the state DB and are generally invisible.
|
||||
The fix writes snapshots under ``~/.hermes/sessions/saved/`` and prints
|
||||
the absolute path plus the resume hint for the live session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
# Clear any cached hermes_home computation
|
||||
import hermes_constants
|
||||
if hasattr(hermes_constants, "_hermes_home_cache"):
|
||||
hermes_constants._hermes_home_cache = None
|
||||
return home
|
||||
|
||||
|
||||
def _make_stub_cli(history):
|
||||
"""Build a minimal object exposing just what save_conversation uses."""
|
||||
return SimpleNamespace(
|
||||
conversation_history=history,
|
||||
model="test-model",
|
||||
session_id="20260101_120000_abc123",
|
||||
session_start=datetime(2026, 1, 1, 12, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def test_save_conversation_writes_under_hermes_home(hermes_home, tmp_path, monkeypatch, capsys):
|
||||
"""Snapshot must land under ~/.hermes/sessions/saved/, not CWD."""
|
||||
# Change CWD to a different directory to prove the file does NOT go there.
|
||||
work = tmp_path / "somewhere-else"
|
||||
work.mkdir()
|
||||
monkeypatch.chdir(work)
|
||||
|
||||
# Import fresh to pick up the HERMES_HOME fixture
|
||||
for mod in [m for m in sys.modules if m.startswith("cli") or m == "hermes_constants"]:
|
||||
sys.modules.pop(mod, None)
|
||||
|
||||
import cli # noqa: F401 (module under test)
|
||||
|
||||
stub = _make_stub_cli([
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
])
|
||||
|
||||
# Call the unbound method against our stub.
|
||||
cli.HermesCLI.save_conversation(stub)
|
||||
|
||||
# File must NOT be in CWD
|
||||
cwd_leak = list(work.glob("hermes_conversation_*.json"))
|
||||
assert not cwd_leak, f"snapshot leaked to CWD: {cwd_leak}"
|
||||
|
||||
# File MUST be under ~/.hermes/sessions/saved/
|
||||
saved_dir = hermes_home / "sessions" / "saved"
|
||||
assert saved_dir.is_dir(), "expected saved/ subdirectory to be created"
|
||||
files = list(saved_dir.glob("hermes_conversation_*.json"))
|
||||
assert len(files) == 1, files
|
||||
|
||||
payload = json.loads(files[0].read_text())
|
||||
assert payload["model"] == "test-model"
|
||||
assert payload["session_id"] == "20260101_120000_abc123"
|
||||
assert payload["messages"] == [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
]
|
||||
|
||||
# User-facing message must include the absolute path AND the resume hint.
|
||||
out = capsys.readouterr().out
|
||||
assert str(files[0]) in out, out
|
||||
assert "hermes --resume 20260101_120000_abc123" in out, out
|
||||
|
||||
|
||||
def test_save_conversation_empty_history_does_nothing(hermes_home, capsys):
|
||||
for mod in [m for m in sys.modules if m.startswith("cli") or m == "hermes_constants"]:
|
||||
sys.modules.pop(mod, None)
|
||||
import cli
|
||||
|
||||
stub = _make_stub_cli([])
|
||||
cli.HermesCLI.save_conversation(stub)
|
||||
|
||||
saved_dir = hermes_home / "sessions" / "saved"
|
||||
assert not saved_dir.exists() or not list(saved_dir.iterdir())
|
||||
out = capsys.readouterr().out
|
||||
assert "No conversation to save" in out
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -186,6 +186,91 @@ class TestBusySessionAck:
|
||||
assert "respond once the current task finishes" in content
|
||||
assert "Interrupting" not in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_steer_mode_calls_agent_steer_no_interrupt_no_queue(self):
|
||||
"""busy_input_mode='steer' injects via agent.steer() and skips queueing."""
|
||||
runner, sentinel = _make_runner()
|
||||
runner._busy_input_mode = "steer"
|
||||
adapter = _make_adapter()
|
||||
|
||||
event = _make_event(text="also check the tests")
|
||||
sk = build_session_key(event.source)
|
||||
runner.adapters[event.source.platform] = adapter
|
||||
|
||||
agent = MagicMock()
|
||||
agent.steer = MagicMock(return_value=True)
|
||||
runner._running_agents[sk] = agent
|
||||
|
||||
with patch("gateway.run.merge_pending_message_event") as mock_merge:
|
||||
await runner._handle_active_session_busy_message(event, sk)
|
||||
|
||||
# VERIFY: Agent was steered, NOT interrupted
|
||||
agent.steer.assert_called_once_with("also check the tests")
|
||||
agent.interrupt.assert_not_called()
|
||||
|
||||
# VERIFY: No queueing — successful steer must NOT replay as next turn
|
||||
mock_merge.assert_not_called()
|
||||
|
||||
# VERIFY: Ack mentions steer wording
|
||||
adapter._send_with_retry.assert_called_once()
|
||||
call_kwargs = adapter._send_with_retry.call_args
|
||||
content = call_kwargs.kwargs.get("content") or call_kwargs[1].get("content", "")
|
||||
assert "Steered" in content or "steer" in content.lower()
|
||||
assert "Interrupting" not in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_steer_mode_falls_back_to_queue_when_agent_rejects(self):
|
||||
"""If agent.steer() returns False, fall back to queue behavior."""
|
||||
runner, sentinel = _make_runner()
|
||||
runner._busy_input_mode = "steer"
|
||||
adapter = _make_adapter()
|
||||
|
||||
event = _make_event(text="empty or rejected")
|
||||
sk = build_session_key(event.source)
|
||||
runner.adapters[event.source.platform] = adapter
|
||||
|
||||
agent = MagicMock()
|
||||
agent.steer = MagicMock(return_value=False) # rejected
|
||||
runner._running_agents[sk] = agent
|
||||
|
||||
with patch("gateway.run.merge_pending_message_event") as mock_merge:
|
||||
await runner._handle_active_session_busy_message(event, sk)
|
||||
|
||||
agent.steer.assert_called_once()
|
||||
agent.interrupt.assert_not_called()
|
||||
# Fell back to queue semantics: event was merged into pending messages
|
||||
mock_merge.assert_called_once()
|
||||
|
||||
# Ack uses queue-mode wording (not steer, not interrupt)
|
||||
call_kwargs = adapter._send_with_retry.call_args
|
||||
content = call_kwargs.kwargs.get("content") or call_kwargs[1].get("content", "")
|
||||
assert "Queued for the next turn" in content
|
||||
assert "Steered" not in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_steer_mode_falls_back_to_queue_when_agent_pending(self):
|
||||
"""If agent is still starting (sentinel), steer mode falls back to queue."""
|
||||
runner, sentinel = _make_runner()
|
||||
runner._busy_input_mode = "steer"
|
||||
adapter = _make_adapter()
|
||||
|
||||
event = _make_event(text="arrived too early")
|
||||
sk = build_session_key(event.source)
|
||||
runner.adapters[event.source.platform] = adapter
|
||||
|
||||
# Agent is still being set up — sentinel in place
|
||||
runner._running_agents[sk] = sentinel
|
||||
|
||||
with patch("gateway.run.merge_pending_message_event") as mock_merge:
|
||||
await runner._handle_active_session_busy_message(event, sk)
|
||||
|
||||
# Event was queued instead of steered
|
||||
mock_merge.assert_called_once()
|
||||
|
||||
call_kwargs = adapter._send_with_retry.call_args
|
||||
content = call_kwargs.kwargs.get("content") or call_kwargs[1].get("content", "")
|
||||
assert "Queued for the next turn" in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debounce_suppresses_rapid_acks(self):
|
||||
"""Second message within 30s should NOT send another ack."""
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -186,12 +186,18 @@ class TestPlatformDefaults:
|
||||
assert resolve_display_setting({}, plat, "tool_progress") == "all", plat
|
||||
|
||||
def test_medium_tier_platforms(self):
|
||||
"""Slack, Mattermost, Matrix default to 'new' tool progress."""
|
||||
"""Mattermost, Matrix, Feishu, WhatsApp default to 'new' tool progress."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
for plat in ("slack", "mattermost", "matrix", "feishu", "whatsapp"):
|
||||
for plat in ("mattermost", "matrix", "feishu", "whatsapp"):
|
||||
assert resolve_display_setting({}, plat, "tool_progress") == "new", plat
|
||||
|
||||
def test_slack_defaults_tool_progress_off(self):
|
||||
"""Slack defaults to quiet tool progress (permanent chat noise otherwise)."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
assert resolve_display_setting({}, "slack", "tool_progress") == "off"
|
||||
|
||||
def test_low_tier_platforms(self):
|
||||
"""Signal, BlueBubbles, etc. default to 'off' tool progress."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
@@ -241,7 +247,7 @@ class TestConfigMigration:
|
||||
},
|
||||
},
|
||||
}
|
||||
config_path.write_text(yaml.dump(config))
|
||||
config_path.write_text(yaml.dump(config), encoding="utf-8")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
# Re-import to pick up the new HERMES_HOME
|
||||
@@ -251,7 +257,7 @@ class TestConfigMigration:
|
||||
|
||||
result = cfg_mod.migrate_config(interactive=False, quiet=True)
|
||||
# Re-read config
|
||||
updated = yaml.safe_load(config_path.read_text())
|
||||
updated = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
platforms = updated.get("display", {}).get("platforms", {})
|
||||
assert platforms.get("signal", {}).get("tool_progress") == "off"
|
||||
assert platforms.get("telegram", {}).get("tool_progress") == "all"
|
||||
@@ -268,7 +274,7 @@ class TestConfigMigration:
|
||||
"platforms": {"telegram": {"tool_progress": "verbose"}},
|
||||
},
|
||||
}
|
||||
config_path.write_text(yaml.dump(config))
|
||||
config_path.write_text(yaml.dump(config), encoding="utf-8")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
import importlib
|
||||
@@ -276,7 +282,7 @@ class TestConfigMigration:
|
||||
importlib.reload(cfg_mod)
|
||||
|
||||
cfg_mod.migrate_config(interactive=False, quiet=True)
|
||||
updated = yaml.safe_load(config_path.read_text())
|
||||
updated = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
# Existing "verbose" should NOT be overwritten by legacy "off"
|
||||
assert updated["display"]["platforms"]["telegram"]["tool_progress"] == "verbose"
|
||||
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -702,6 +735,7 @@ class TestSlackDownloadSlackFileBytes:
|
||||
fake_response = MagicMock()
|
||||
fake_response.content = b"raw bytes here"
|
||||
fake_response.raise_for_status = MagicMock()
|
||||
fake_response.headers = {"content-type": "application/pdf"}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=fake_response)
|
||||
@@ -717,6 +751,29 @@ class TestSlackDownloadSlackFileBytes:
|
||||
result = asyncio.run(run())
|
||||
assert result == b"raw bytes here"
|
||||
|
||||
def test_rejects_html_response(self):
|
||||
"""Slack HTML sign-in pages should not be accepted as file bytes."""
|
||||
adapter = _make_slack_adapter()
|
||||
|
||||
fake_response = MagicMock()
|
||||
fake_response.content = b"<!DOCTYPE html><html><title>Slack</title></html>"
|
||||
fake_response.raise_for_status = MagicMock()
|
||||
fake_response.headers = {"content-type": "text/html; charset=utf-8"}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=fake_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
async def run():
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
await adapter._download_slack_file_bytes(
|
||||
"https://files.slack.com/file.bin"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="HTML instead of file bytes"):
|
||||
asyncio.run(run())
|
||||
|
||||
def test_retries_on_429_then_succeeds(self):
|
||||
"""429 on first attempt is retried; raw bytes returned on second."""
|
||||
adapter = _make_slack_adapter()
|
||||
@@ -724,6 +781,7 @@ class TestSlackDownloadSlackFileBytes:
|
||||
ok_response = MagicMock()
|
||||
ok_response.content = b"final bytes"
|
||||
ok_response.raise_for_status = MagicMock()
|
||||
ok_response.headers = {"content-type": "application/pdf"}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(
|
||||
|
||||
@@ -77,6 +77,19 @@ class TestMessageDeduplicatorTTL:
|
||||
assert "old-0" not in dedup._seen
|
||||
assert "new-0" in dedup._seen
|
||||
|
||||
def test_max_size_eviction_caps_fresh_entries(self):
|
||||
"""Fresh entries must still be capped to max_size on overflow."""
|
||||
dedup = MessageDeduplicator(max_size=2, ttl_seconds=60)
|
||||
|
||||
dedup.is_duplicate("msg-1")
|
||||
dedup.is_duplicate("msg-2")
|
||||
dedup.is_duplicate("msg-3")
|
||||
|
||||
assert len(dedup._seen) == 2
|
||||
assert "msg-1" not in dedup._seen
|
||||
assert "msg-2" in dedup._seen
|
||||
assert "msg-3" in dedup._seen
|
||||
|
||||
def test_ttl_zero_means_no_dedup(self):
|
||||
"""With TTL=0, all entries expire immediately."""
|
||||
dedup = MessageDeduplicator(ttl_seconds=0)
|
||||
|
||||
@@ -77,6 +77,46 @@ class TestFindSessionId:
|
||||
|
||||
assert result == "sess_topic_a"
|
||||
|
||||
def test_user_id_disambiguates_same_group_chat(self, tmp_path):
|
||||
sessions_dir, index_file = _setup_sessions(tmp_path, {
|
||||
"alice": {
|
||||
"session_id": "sess_alice",
|
||||
"origin": {"platform": "telegram", "chat_id": "-1001", "user_id": "alice"},
|
||||
"updated_at": "2026-01-01T00:00:00",
|
||||
},
|
||||
"bob": {
|
||||
"session_id": "sess_bob",
|
||||
"origin": {"platform": "telegram", "chat_id": "-1001", "user_id": "bob"},
|
||||
"updated_at": "2026-02-01T00:00:00",
|
||||
},
|
||||
})
|
||||
|
||||
with patch.object(mirror_mod, "_SESSIONS_DIR", sessions_dir), \
|
||||
patch.object(mirror_mod, "_SESSIONS_INDEX", index_file):
|
||||
result = _find_session_id("telegram", "-1001", user_id="alice")
|
||||
|
||||
assert result == "sess_alice"
|
||||
|
||||
def test_ambiguous_same_group_chat_without_user_id_returns_none(self, tmp_path):
|
||||
sessions_dir, index_file = _setup_sessions(tmp_path, {
|
||||
"alice": {
|
||||
"session_id": "sess_alice",
|
||||
"origin": {"platform": "telegram", "chat_id": "-1001", "user_id": "alice"},
|
||||
"updated_at": "2026-01-01T00:00:00",
|
||||
},
|
||||
"bob": {
|
||||
"session_id": "sess_bob",
|
||||
"origin": {"platform": "telegram", "chat_id": "-1001", "user_id": "bob"},
|
||||
"updated_at": "2026-02-01T00:00:00",
|
||||
},
|
||||
})
|
||||
|
||||
with patch.object(mirror_mod, "_SESSIONS_DIR", sessions_dir), \
|
||||
patch.object(mirror_mod, "_SESSIONS_INDEX", index_file):
|
||||
result = _find_session_id("telegram", "-1001")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_no_match_returns_none(self, tmp_path):
|
||||
sessions_dir, index_file = _setup_sessions(tmp_path, {
|
||||
"sess": {
|
||||
@@ -189,6 +229,35 @@ class TestMirrorToSession:
|
||||
assert (sessions_dir / "sess_topic_a.jsonl").exists()
|
||||
assert not (sessions_dir / "sess_topic_b.jsonl").exists()
|
||||
|
||||
def test_successful_mirror_uses_user_id_for_group_session(self, tmp_path):
|
||||
sessions_dir, index_file = _setup_sessions(tmp_path, {
|
||||
"alice": {
|
||||
"session_id": "sess_alice",
|
||||
"origin": {"platform": "telegram", "chat_id": "-1001", "user_id": "alice"},
|
||||
"updated_at": "2026-01-01T00:00:00",
|
||||
},
|
||||
"bob": {
|
||||
"session_id": "sess_bob",
|
||||
"origin": {"platform": "telegram", "chat_id": "-1001", "user_id": "bob"},
|
||||
"updated_at": "2026-02-01T00:00:00",
|
||||
},
|
||||
})
|
||||
|
||||
with patch.object(mirror_mod, "_SESSIONS_DIR", sessions_dir), \
|
||||
patch.object(mirror_mod, "_SESSIONS_INDEX", index_file), \
|
||||
patch("gateway.mirror._append_to_sqlite"):
|
||||
result = mirror_to_session(
|
||||
"telegram",
|
||||
"-1001",
|
||||
"Hello group!",
|
||||
source_label="cli",
|
||||
user_id="alice",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert (sessions_dir / "sess_alice.jsonl").exists()
|
||||
assert not (sessions_dir / "sess_bob.jsonl").exists()
|
||||
|
||||
def test_no_matching_session(self, tmp_path):
|
||||
sessions_dir, index_file = _setup_sessions(tmp_path, {})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -90,9 +90,21 @@ def test_load_busy_input_mode_prefers_env_then_config_then_default(tmp_path, mon
|
||||
)
|
||||
assert gateway_run.GatewayRunner._load_busy_input_mode() == "queue"
|
||||
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"display:\n busy_input_mode: steer\n", encoding="utf-8"
|
||||
)
|
||||
assert gateway_run.GatewayRunner._load_busy_input_mode() == "steer"
|
||||
|
||||
monkeypatch.setenv("HERMES_GATEWAY_BUSY_INPUT_MODE", "interrupt")
|
||||
assert gateway_run.GatewayRunner._load_busy_input_mode() == "interrupt"
|
||||
|
||||
monkeypatch.setenv("HERMES_GATEWAY_BUSY_INPUT_MODE", "steer")
|
||||
assert gateway_run.GatewayRunner._load_busy_input_mode() == "steer"
|
||||
|
||||
# Unknown values fall through to the safe default
|
||||
monkeypatch.setenv("HERMES_GATEWAY_BUSY_INPUT_MODE", "bogus")
|
||||
assert gateway_run.GatewayRunner._load_busy_input_mode() == "interrupt"
|
||||
|
||||
|
||||
def test_load_restart_drain_timeout_prefers_env_then_config_then_default(
|
||||
tmp_path, monkeypatch, caplog
|
||||
|
||||
@@ -245,6 +245,7 @@ class TestBuildSessionContextPrompt:
|
||||
assert "Slack" in prompt
|
||||
assert "cannot search" in prompt.lower()
|
||||
assert "pin" in prompt.lower()
|
||||
assert "current message's slack block/attachment payload" in prompt.lower()
|
||||
|
||||
def test_discord_prompt_with_channel_topic(self):
|
||||
"""Channel topic should appear in the session context prompt."""
|
||||
|
||||
@@ -76,6 +76,7 @@ def _make_resume_runner():
|
||||
runner._running_agents_ts = {}
|
||||
runner._busy_ack_ts = {}
|
||||
runner._pending_approvals = {}
|
||||
runner._update_prompt_pending = {}
|
||||
runner._agent_cache_lock = None
|
||||
runner.session_store = MagicMock()
|
||||
runner.session_store.get_or_create_session.return_value = current_entry
|
||||
@@ -102,6 +103,7 @@ def _make_branch_runner():
|
||||
runner._running_agents_ts = {}
|
||||
runner._busy_ack_ts = {}
|
||||
runner._pending_approvals = {}
|
||||
runner._update_prompt_pending = {}
|
||||
runner._agent_cache_lock = None
|
||||
runner.session_store = MagicMock()
|
||||
runner.session_store.get_or_create_session.return_value = current_entry
|
||||
@@ -127,6 +129,8 @@ async def test_resume_clears_session_scoped_approval_and_yolo_state():
|
||||
enable_session_yolo(other_key)
|
||||
runner._pending_approvals[session_key] = {"command": "rm -rf /tmp/demo"}
|
||||
runner._pending_approvals[other_key] = {"command": "rm -rf /tmp/other"}
|
||||
runner._update_prompt_pending[session_key] = True
|
||||
runner._update_prompt_pending[other_key] = True
|
||||
|
||||
result = await runner._handle_resume_command(_make_event("/resume Resumed Work"))
|
||||
|
||||
@@ -134,9 +138,11 @@ async def test_resume_clears_session_scoped_approval_and_yolo_state():
|
||||
assert is_approved(session_key, "recursive delete") is False
|
||||
assert is_session_yolo_enabled(session_key) is False
|
||||
assert session_key not in runner._pending_approvals
|
||||
assert session_key not in runner._update_prompt_pending
|
||||
assert is_approved(other_key, "recursive delete") is True
|
||||
assert is_session_yolo_enabled(other_key) is True
|
||||
assert other_key in runner._pending_approvals
|
||||
assert other_key in runner._update_prompt_pending
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -150,6 +156,8 @@ async def test_branch_clears_session_scoped_approval_and_yolo_state():
|
||||
enable_session_yolo(other_key)
|
||||
runner._pending_approvals[session_key] = {"command": "rm -rf /tmp/demo"}
|
||||
runner._pending_approvals[other_key] = {"command": "rm -rf /tmp/other"}
|
||||
runner._update_prompt_pending[session_key] = True
|
||||
runner._update_prompt_pending[other_key] = True
|
||||
|
||||
result = await runner._handle_branch_command(_make_event("/branch"))
|
||||
|
||||
@@ -157,9 +165,11 @@ async def test_branch_clears_session_scoped_approval_and_yolo_state():
|
||||
assert is_approved(session_key, "recursive delete") is False
|
||||
assert is_session_yolo_enabled(session_key) is False
|
||||
assert session_key not in runner._pending_approvals
|
||||
assert session_key not in runner._update_prompt_pending
|
||||
assert is_approved(other_key, "recursive delete") is True
|
||||
assert is_session_yolo_enabled(other_key) is True
|
||||
assert other_key in runner._pending_approvals
|
||||
assert other_key in runner._update_prompt_pending
|
||||
|
||||
|
||||
def test_clear_session_boundary_security_state_is_scoped():
|
||||
@@ -172,6 +182,7 @@ def test_clear_session_boundary_security_state_is_scoped():
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._pending_approvals = {}
|
||||
runner._update_prompt_pending = {}
|
||||
|
||||
source = _make_source()
|
||||
session_key = build_session_key(source)
|
||||
@@ -183,6 +194,8 @@ def test_clear_session_boundary_security_state_is_scoped():
|
||||
enable_session_yolo(other_key)
|
||||
runner._pending_approvals[session_key] = {"command": "rm -rf /tmp/demo"}
|
||||
runner._pending_approvals[other_key] = {"command": "rm -rf /tmp/other"}
|
||||
runner._update_prompt_pending[session_key] = True
|
||||
runner._update_prompt_pending[other_key] = True
|
||||
|
||||
runner._clear_session_boundary_security_state(session_key)
|
||||
|
||||
@@ -190,11 +203,14 @@ def test_clear_session_boundary_security_state_is_scoped():
|
||||
assert is_approved(session_key, "recursive delete") is False
|
||||
assert is_session_yolo_enabled(session_key) is False
|
||||
assert session_key not in runner._pending_approvals
|
||||
assert session_key not in runner._update_prompt_pending
|
||||
# Other session untouched
|
||||
assert is_approved(other_key, "recursive delete") is True
|
||||
assert is_session_yolo_enabled(other_key) is True
|
||||
assert other_key in runner._pending_approvals
|
||||
assert other_key in runner._update_prompt_pending
|
||||
|
||||
# Empty session_key is a no-op
|
||||
runner._clear_session_boundary_security_state("")
|
||||
assert is_approved(other_key, "recursive delete") is True
|
||||
assert other_key in runner._update_prompt_pending
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"""Regression tests for the TUI gateway's ``session.list`` handler.
|
||||
|
||||
Reported during TUI v2 blitz retest: the ``/resume`` modal inside a TUI
|
||||
session only surfaced ``tui``/``cli`` rows, hiding telegram sessions users
|
||||
could still resume directly via ``hermes --tui --resume <id>``.
|
||||
|
||||
The fix widens the picker to a curated allowlist of user-facing sources
|
||||
(tui/cli + chat adapters) while still filtering internal/system sources.
|
||||
History:
|
||||
- The original implementation hardcoded an allow-list of known gateway
|
||||
sources (``tui, cli, telegram, discord, slack, ...``). New or unlisted
|
||||
sources (``acp``, ``webhook``, user-defined ``HERMES_SESSION_SOURCE``
|
||||
values, newly-added platforms) were silently dropped from the resume
|
||||
picker — users reported "lots of sessions are missing from browse
|
||||
but exist in .hermes/sessions."
|
||||
- The handler now deny-lists only the internal/noisy source ``tool``
|
||||
(sub-agent runs) and surfaces every other source to the picker.
|
||||
- The default ``limit`` raised from 20 to 200 so longer-running users
|
||||
can scroll through their history without hitting an artificial cap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,42 +28,64 @@ class _StubDB:
|
||||
return list(self.rows)
|
||||
|
||||
|
||||
def _call(limit: int = 20):
|
||||
def _call(limit: int | None = None):
|
||||
params: dict = {}
|
||||
if limit is not None:
|
||||
params["limit"] = limit
|
||||
return server.handle_request({
|
||||
"id": "1",
|
||||
"method": "session.list",
|
||||
"params": {"limit": limit},
|
||||
"params": params,
|
||||
})
|
||||
|
||||
|
||||
def test_session_list_includes_telegram_but_filters_internal_sources(monkeypatch):
|
||||
def test_session_list_surfaces_all_user_facing_sources(monkeypatch):
|
||||
"""acp / webhook / custom sources should all appear; only ``tool`` is hidden."""
|
||||
rows = [
|
||||
{"id": "tui-1", "source": "tui", "started_at": 9},
|
||||
{"id": "tool-1", "source": "tool", "started_at": 8},
|
||||
{"id": "tg-1", "source": "telegram", "started_at": 7},
|
||||
{"id": "acp-1", "source": "acp", "started_at": 6},
|
||||
{"id": "cli-1", "source": "cli", "started_at": 5},
|
||||
{"id": "webhook-1", "source": "webhook", "started_at": 4},
|
||||
{"id": "custom-1", "source": "my-custom-source", "started_at": 3},
|
||||
]
|
||||
db = _StubDB(rows)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: db)
|
||||
|
||||
resp = _call(limit=10)
|
||||
sessions = resp["result"]["sessions"]
|
||||
ids = [s["id"] for s in sessions]
|
||||
ids = [s["id"] for s in resp["result"]["sessions"]]
|
||||
|
||||
assert "tg-1" in ids and "tui-1" in ids and "cli-1" in ids, ids
|
||||
assert "tool-1" not in ids and "acp-1" not in ids, ids
|
||||
# Every human-facing source — including previously-hidden acp, webhook,
|
||||
# and custom sources — must surface in the picker now.
|
||||
assert "tg-1" in ids
|
||||
assert "tui-1" in ids
|
||||
assert "cli-1" in ids
|
||||
assert "acp-1" in ids, "acp sessions were being hidden by the old allow-list"
|
||||
assert "webhook-1" in ids, "webhook sessions were being hidden by the old allow-list"
|
||||
assert "custom-1" in ids, "custom HERMES_SESSION_SOURCE values were being hidden"
|
||||
|
||||
# Only internal sub-agent runs stay hidden.
|
||||
assert "tool-1" not in ids
|
||||
|
||||
|
||||
def test_session_list_fetches_wider_window_before_filtering(monkeypatch):
|
||||
def test_session_list_default_limit_is_200(monkeypatch):
|
||||
"""Default limit should be wide enough for long-running users."""
|
||||
db = _StubDB([{"id": "x", "source": "cli", "started_at": 1}])
|
||||
monkeypatch.setattr(server, "_get_db", lambda: db)
|
||||
|
||||
_call() # no explicit limit
|
||||
# fetch_limit = max(limit * 2, 200); limit defaults to 200, so 400.
|
||||
assert db.calls[0].get("limit") == 400, db.calls[0]
|
||||
|
||||
|
||||
def test_session_list_respects_explicit_limit(monkeypatch):
|
||||
db = _StubDB([{"id": "x", "source": "cli", "started_at": 1}])
|
||||
monkeypatch.setattr(server, "_get_db", lambda: db)
|
||||
|
||||
_call(limit=10)
|
||||
|
||||
assert len(db.calls) == 1
|
||||
assert db.calls[0].get("source") is None, db.calls[0]
|
||||
assert db.calls[0].get("limit") == 100, db.calls[0]
|
||||
# fetch_limit = max(limit * 2, 200) = 200 when limit is small.
|
||||
assert db.calls[0].get("limit") == 200, db.calls[0]
|
||||
|
||||
|
||||
def test_session_list_preserves_ordering_after_filter(monkeypatch):
|
||||
@@ -66,6 +93,7 @@ def test_session_list_preserves_ordering_after_filter(monkeypatch):
|
||||
{"id": "newest", "source": "telegram", "started_at": 5},
|
||||
{"id": "internal", "source": "tool", "started_at": 4},
|
||||
{"id": "middle", "source": "tui", "started_at": 3},
|
||||
{"id": "also-visible", "source": "webhook", "started_at": 2},
|
||||
{"id": "oldest", "source": "discord", "started_at": 1},
|
||||
]
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _StubDB(rows))
|
||||
@@ -73,4 +101,4 @@ def test_session_list_preserves_ordering_after_filter(monkeypatch):
|
||||
resp = _call()
|
||||
ids = [s["id"] for s in resp["result"]["sessions"]]
|
||||
|
||||
assert ids == ["newest", "middle", "oldest"]
|
||||
assert ids == ["newest", "middle", "also-visible", "oldest"]
|
||||
|
||||
@@ -81,11 +81,13 @@ async def test_new_command_clears_session_model_override():
|
||||
"api_mode": "openai",
|
||||
}
|
||||
runner._session_reasoning_overrides[session_key] = {"enabled": True, "effort": "high"}
|
||||
runner._pending_model_notes[session_key] = "[Note: switched to gpt-4o.]"
|
||||
|
||||
await runner._handle_reset_command(_make_event("/new"))
|
||||
|
||||
assert session_key not in runner._session_model_overrides
|
||||
assert session_key not in runner._session_reasoning_overrides
|
||||
assert session_key not in runner._pending_model_notes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -126,6 +128,8 @@ async def test_new_command_only_clears_own_session():
|
||||
}
|
||||
runner._session_reasoning_overrides[session_key] = {"enabled": True, "effort": "high"}
|
||||
runner._session_reasoning_overrides[other_key] = {"enabled": True, "effort": "low"}
|
||||
runner._pending_model_notes[session_key] = "[Note: switched to gpt-4o.]"
|
||||
runner._pending_model_notes[other_key] = "[Note: switched to claude-sonnet-4-6.]"
|
||||
|
||||
await runner._handle_reset_command(_make_event("/new"))
|
||||
|
||||
@@ -133,3 +137,5 @@ async def test_new_command_only_clears_own_session():
|
||||
assert other_key in runner._session_model_overrides
|
||||
assert session_key not in runner._session_reasoning_overrides
|
||||
assert other_key in runner._session_reasoning_overrides
|
||||
assert session_key not in runner._pending_model_notes
|
||||
assert other_key in runner._pending_model_notes
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Regression tests for gateway shutdown cleaning up cached agent memory providers (issue #11205).
|
||||
|
||||
When the gateway shuts down, ``stop()`` called ``_finalize_shutdown_agents()``
|
||||
which only drained agents in ``_running_agents``. Idle agents sitting in
|
||||
``_agent_cache`` (LRU cache) were never cleaned up, so their
|
||||
``MemoryProvider.on_session_end()`` hooks never fired.
|
||||
|
||||
The fix adds an explicit sweep of ``_agent_cache`` after
|
||||
``_finalize_shutdown_agents`` in the ``_stop_impl`` coroutine.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Import the module (not the class) to reach stop() and helpers
|
||||
import gateway.run as gw_mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeGateway:
|
||||
"""Minimal stand-in with just enough state for ``stop()`` to run."""
|
||||
|
||||
def __init__(self):
|
||||
self._running = True
|
||||
self._draining = False
|
||||
self._restart_requested = False
|
||||
self._restart_detached = False
|
||||
self._restart_via_service = False
|
||||
self._stop_task = None
|
||||
self._exit_cleanly = False
|
||||
self._exit_with_failure = False
|
||||
self._exit_reason = None
|
||||
self._exit_code = None
|
||||
self._restart_drain_timeout = 0.01
|
||||
self._running_agents = {}
|
||||
self._running_agents_ts = {}
|
||||
self._agent_cache = OrderedDict()
|
||||
self._agent_cache_lock = threading.Lock()
|
||||
self.adapters = {}
|
||||
self._background_tasks = set()
|
||||
self._failed_platforms = []
|
||||
self._shutdown_event = asyncio.Event()
|
||||
self._pending_messages = {}
|
||||
self._pending_approvals = {}
|
||||
self._busy_ack_ts = {}
|
||||
|
||||
def _running_agent_count(self):
|
||||
return len(self._running_agents)
|
||||
|
||||
def _update_runtime_status(self, *_a, **_kw):
|
||||
pass
|
||||
|
||||
async def _notify_active_sessions_of_shutdown(self):
|
||||
pass
|
||||
|
||||
async def _drain_active_agents(self, timeout):
|
||||
return {}, False
|
||||
|
||||
def _finalize_shutdown_agents(self, agents):
|
||||
for agent in agents.values():
|
||||
self._cleanup_agent_resources(agent)
|
||||
|
||||
def _cleanup_agent_resources(self, agent):
|
||||
if agent is None:
|
||||
return
|
||||
try:
|
||||
if hasattr(agent, "shutdown_memory_provider"):
|
||||
agent.shutdown_memory_provider()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if hasattr(agent, "close"):
|
||||
agent.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _evict_cached_agent(self, key):
|
||||
pass
|
||||
|
||||
|
||||
def _make_mock_agent():
|
||||
a = MagicMock()
|
||||
a.shutdown_memory_provider = MagicMock()
|
||||
a.close = MagicMock()
|
||||
return a
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCachedAgentCleanupOnShutdown:
|
||||
"""Verify that ``stop()`` calls ``_cleanup_agent_resources`` on idle
|
||||
cached agents, triggering ``shutdown_memory_provider()`` (which calls
|
||||
``on_session_end``)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cached_agent_memory_provider_shut_down(self):
|
||||
"""A cached agent's shutdown_memory_provider is called during gateway stop."""
|
||||
gw = _FakeGateway()
|
||||
agent = _make_mock_agent()
|
||||
gw._agent_cache["session-1"] = (agent, "sig-123")
|
||||
|
||||
# Call the real stop() from GatewayRunner
|
||||
await gw_mod.GatewayRunner.stop(gw)
|
||||
|
||||
agent.shutdown_memory_provider.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_cleared_after_shutdown(self):
|
||||
"""The _agent_cache dict is cleared after stop."""
|
||||
gw = _FakeGateway()
|
||||
agent = _make_mock_agent()
|
||||
gw._agent_cache["s1"] = (agent, "sig1")
|
||||
|
||||
await gw_mod.GatewayRunner.stop(gw)
|
||||
|
||||
assert len(gw._agent_cache) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_cached_agents_no_error(self):
|
||||
"""stop() works fine when _agent_cache is empty."""
|
||||
gw = _FakeGateway()
|
||||
|
||||
await gw_mod.GatewayRunner.stop(gw) # Should not raise
|
||||
|
||||
assert len(gw._agent_cache) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_cached_agents_all_cleaned(self):
|
||||
"""All cached agents get cleaned up."""
|
||||
gw = _FakeGateway()
|
||||
agents = []
|
||||
for i in range(5):
|
||||
a = _make_mock_agent()
|
||||
agents.append(a)
|
||||
gw._agent_cache[f"s{i}"] = (a, f"sig{i}")
|
||||
|
||||
await gw_mod.GatewayRunner.stop(gw)
|
||||
|
||||
for a in agents:
|
||||
a.shutdown_memory_provider.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_survives_agent_exception(self):
|
||||
"""An exception from one agent's shutdown doesn't prevent others."""
|
||||
gw = _FakeGateway()
|
||||
|
||||
bad = _make_mock_agent()
|
||||
bad.shutdown_memory_provider.side_effect = RuntimeError("boom")
|
||||
bad.close.side_effect = RuntimeError("boom")
|
||||
|
||||
good = _make_mock_agent()
|
||||
|
||||
gw._agent_cache["bad"] = (bad, "sig-bad")
|
||||
gw._agent_cache["good"] = (good, "sig-good")
|
||||
|
||||
await gw_mod.GatewayRunner.stop(gw)
|
||||
|
||||
# The good agent should still be cleaned up
|
||||
good.shutdown_memory_provider.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plain_agent_not_tuple(self):
|
||||
"""Cache entries that aren't tuples (just bare agents) are also cleaned."""
|
||||
gw = _FakeGateway()
|
||||
agent = _make_mock_agent()
|
||||
gw._agent_cache["s1"] = agent # Not a tuple
|
||||
|
||||
await gw_mod.GatewayRunner.stop(gw)
|
||||
|
||||
agent.shutdown_memory_provider.assert_called_once()
|
||||
assert len(gw._agent_cache) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_entry_skipped(self):
|
||||
"""A None cache entry doesn't cause errors."""
|
||||
gw = _FakeGateway()
|
||||
gw._agent_cache["s1"] = None
|
||||
|
||||
await gw_mod.GatewayRunner.stop(gw)
|
||||
|
||||
assert len(gw._agent_cache) == 0
|
||||
|
||||
|
||||
class TestRunningAgentsNotDoubleCleaned:
|
||||
"""Verify behavior when agents appear in both _running_agents and _agent_cache."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_running_and_cached_agent_cleaned_at_least_once(self):
|
||||
"""An agent in both _running_agents and _agent_cache gets
|
||||
shutdown_memory_provider called at least once."""
|
||||
gw = _FakeGateway()
|
||||
shared = _make_mock_agent()
|
||||
|
||||
gw._running_agents["s1"] = shared
|
||||
gw._agent_cache["s1"] = (shared, "sig1")
|
||||
|
||||
await gw_mod.GatewayRunner.stop(gw)
|
||||
|
||||
# Called at least once — either from _finalize_shutdown_agents
|
||||
# or from the cache sweep (or both)
|
||||
assert shared.shutdown_memory_provider.call_count >= 1
|
||||
+669
-4
@@ -11,7 +11,7 @@ We mock the slack modules at import time to avoid collection errors.
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch, call
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -21,6 +21,7 @@ from gateway.platforms.base import (
|
||||
MessageType,
|
||||
SendResult,
|
||||
SUPPORTED_DOCUMENT_TYPES,
|
||||
is_host_excluded_by_no_proxy,
|
||||
)
|
||||
|
||||
|
||||
@@ -147,7 +148,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:
|
||||
@@ -175,6 +189,198 @@ class TestSlackConnectCleanup:
|
||||
assert adapter._platform_lock_identity is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSlackProxyBehavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSlackProxyBehavior:
|
||||
def test_no_proxy_helper_matches_slack_hosts(self):
|
||||
assert is_host_excluded_by_no_proxy("slack.com", "localhost,.slack.com")
|
||||
assert is_host_excluded_by_no_proxy("files.slack.com", "localhost slack.com")
|
||||
assert is_host_excluded_by_no_proxy("wss-primary.slack.com", "*")
|
||||
assert not is_host_excluded_by_no_proxy("slack.com", "localhost,.internal.corp")
|
||||
|
||||
def test_resolve_slack_proxy_url_ignores_unsupported_proxy_schemes(self):
|
||||
with patch.object(_slack_mod, "resolve_proxy_url", return_value="socks5://proxy.example.com:1080"):
|
||||
assert _slack_mod._resolve_slack_proxy_url() is None
|
||||
|
||||
def test_resolve_slack_proxy_url_checks_all_slack_hosts(self):
|
||||
with patch.object(_slack_mod, "resolve_proxy_url", return_value="http://proxy.example.com:3128"), \
|
||||
patch.object(_slack_mod, "is_host_excluded_by_no_proxy", side_effect=lambda host: host == "wss-primary.slack.com") as excluded:
|
||||
assert _slack_mod._resolve_slack_proxy_url() is None
|
||||
excluded.assert_has_calls([
|
||||
call("slack.com"),
|
||||
call("files.slack.com"),
|
||||
call("wss-primary.slack.com"),
|
||||
])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_uses_proxy_when_not_bypassed(self):
|
||||
created_apps = []
|
||||
created_clients = []
|
||||
|
||||
class FakeWebClient:
|
||||
def __init__(self, token):
|
||||
self.token = token
|
||||
self.proxy = "constructor-default"
|
||||
suffix = token.split("-")[-1]
|
||||
self.auth_test = AsyncMock(return_value={
|
||||
"team_id": f"T_{suffix}",
|
||||
"user_id": f"U_{suffix}",
|
||||
"user": f"bot-{suffix}",
|
||||
"team": f"Team {suffix}",
|
||||
})
|
||||
created_clients.append(self)
|
||||
|
||||
class FakeApp:
|
||||
def __init__(self, token):
|
||||
self.token = token
|
||||
self.client = FakeWebClient(token)
|
||||
self.registered_events = []
|
||||
self.registered_commands = []
|
||||
self.registered_actions = []
|
||||
created_apps.append(self)
|
||||
|
||||
def event(self, event_type):
|
||||
self.registered_events.append(event_type)
|
||||
|
||||
def decorator(fn):
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
def command(self, command_name):
|
||||
self.registered_commands.append(command_name)
|
||||
|
||||
def decorator(fn):
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
def action(self, action_id):
|
||||
self.registered_actions.append(action_id)
|
||||
|
||||
def decorator(fn):
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
class FakeSocketModeHandler:
|
||||
def __init__(self, app, app_token, proxy=None):
|
||||
self.app = app
|
||||
self.app_token = app_token
|
||||
self.proxy = proxy
|
||||
self.client = MagicMock(proxy="constructor-default")
|
||||
|
||||
def start_async(self):
|
||||
return None
|
||||
|
||||
async def close_async(self):
|
||||
return None
|
||||
|
||||
config = PlatformConfig(enabled=True, token="xoxb-primary,xoxb-secondary")
|
||||
adapter = SlackAdapter(config)
|
||||
|
||||
with patch.object(_slack_mod, "AsyncApp", side_effect=FakeApp), \
|
||||
patch.object(_slack_mod, "AsyncWebClient", side_effect=FakeWebClient), \
|
||||
patch.object(_slack_mod, "AsyncSocketModeHandler", FakeSocketModeHandler), \
|
||||
patch.object(_slack_mod, "_resolve_slack_proxy_url", return_value="http://proxy.example.com:3128"), \
|
||||
patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}, clear=False), \
|
||||
patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \
|
||||
patch("asyncio.create_task", return_value=MagicMock(name="socket-mode-task")):
|
||||
result = await adapter.connect()
|
||||
|
||||
assert result is True
|
||||
assert created_apps[0].client.proxy == "http://proxy.example.com:3128"
|
||||
assert all(client.proxy == "http://proxy.example.com:3128" for client in created_clients)
|
||||
assert adapter._handler is not None
|
||||
assert adapter._handler.proxy == "http://proxy.example.com:3128"
|
||||
assert adapter._handler.client.proxy == "http://proxy.example.com:3128"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_clears_proxy_when_no_proxy_matches_slack(self):
|
||||
created_apps = []
|
||||
created_clients = []
|
||||
|
||||
class FakeWebClient:
|
||||
def __init__(self, token):
|
||||
self.token = token
|
||||
self.proxy = "constructor-default"
|
||||
suffix = token.split("-")[-1]
|
||||
self.auth_test = AsyncMock(return_value={
|
||||
"team_id": f"T_{suffix}",
|
||||
"user_id": f"U_{suffix}",
|
||||
"user": f"bot-{suffix}",
|
||||
"team": f"Team {suffix}",
|
||||
})
|
||||
created_clients.append(self)
|
||||
|
||||
class FakeApp:
|
||||
def __init__(self, token):
|
||||
self.token = token
|
||||
self.client = FakeWebClient(token)
|
||||
self.registered_events = []
|
||||
self.registered_commands = []
|
||||
self.registered_actions = []
|
||||
created_apps.append(self)
|
||||
|
||||
def event(self, event_type):
|
||||
self.registered_events.append(event_type)
|
||||
|
||||
def decorator(fn):
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
def command(self, command_name):
|
||||
self.registered_commands.append(command_name)
|
||||
|
||||
def decorator(fn):
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
def action(self, action_id):
|
||||
self.registered_actions.append(action_id)
|
||||
|
||||
def decorator(fn):
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
class FakeSocketModeHandler:
|
||||
def __init__(self, app, app_token, proxy=None):
|
||||
self.app = app
|
||||
self.app_token = app_token
|
||||
self.proxy = proxy
|
||||
self.client = MagicMock(proxy="constructor-default")
|
||||
|
||||
def start_async(self):
|
||||
return None
|
||||
|
||||
async def close_async(self):
|
||||
return None
|
||||
|
||||
config = PlatformConfig(enabled=True, token="xoxb-primary")
|
||||
adapter = SlackAdapter(config)
|
||||
|
||||
with patch.object(_slack_mod, "AsyncApp", side_effect=FakeApp), \
|
||||
patch.object(_slack_mod, "AsyncWebClient", side_effect=FakeWebClient), \
|
||||
patch.object(_slack_mod, "AsyncSocketModeHandler", FakeSocketModeHandler), \
|
||||
patch.object(_slack_mod, "_resolve_slack_proxy_url", return_value=None), \
|
||||
patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}, clear=False), \
|
||||
patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \
|
||||
patch("asyncio.create_task", return_value=MagicMock(name="socket-mode-task")):
|
||||
result = await adapter.connect()
|
||||
|
||||
assert result is True
|
||||
assert created_apps[0].client.proxy is None
|
||||
assert all(client.proxy is None for client in created_clients)
|
||||
assert adapter._handler is not None
|
||||
assert adapter._handler.proxy is None
|
||||
assert adapter._handler.client.proxy is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSendDocument
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -274,6 +480,40 @@ class TestSendDocument:
|
||||
call_kwargs = adapter._app.client.files_upload_v2.call_args[1]
|
||||
assert call_kwargs["thread_ts"] == "1234567890.123456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_document_thread_upload_marks_bot_participation(self, adapter, tmp_path):
|
||||
test_file = tmp_path / "notes.txt"
|
||||
test_file.write_bytes(b"some notes")
|
||||
|
||||
adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True})
|
||||
|
||||
await adapter.send_document(
|
||||
chat_id="C123",
|
||||
file_path=str(test_file),
|
||||
metadata={"thread_id": "1234567890.123456"},
|
||||
)
|
||||
|
||||
assert "1234567890.123456" in adapter._bot_message_ts
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_document_retries_transient_upload_error(self, adapter, tmp_path):
|
||||
test_file = tmp_path / "notes.txt"
|
||||
test_file.write_bytes(b"some notes")
|
||||
|
||||
adapter._app.client.files_upload_v2 = AsyncMock(
|
||||
side_effect=[RuntimeError("Connection reset by peer"), {"ok": True}]
|
||||
)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock) as sleep_mock:
|
||||
result = await adapter.send_document(
|
||||
chat_id="C123",
|
||||
file_path=str(test_file),
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert adapter._app.client.files_upload_v2.await_count == 2
|
||||
sleep_mock.assert_awaited_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSendVideo
|
||||
@@ -342,15 +582,17 @@ class TestSendVideo:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIncomingDocumentHandling:
|
||||
def _make_event(self, files=None, text="hello", channel_type="im"):
|
||||
def _make_event(self, files=None, text="hello", channel_type="im", blocks=None, attachments=None):
|
||||
"""Build a mock Slack message event with file attachments."""
|
||||
return {
|
||||
"text": text,
|
||||
"user": "U_USER",
|
||||
"channel": "C123",
|
||||
"channel": "D123",
|
||||
"channel_type": channel_type,
|
||||
"ts": "1234567890.000001",
|
||||
"files": files or [],
|
||||
"blocks": blocks or [],
|
||||
"attachments": attachments or [],
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -415,6 +657,36 @@ class TestIncomingDocumentHandling:
|
||||
msg_event = adapter.handle_message.call_args[0][0]
|
||||
assert "# Title" in msg_event.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_json_snippet_injects_content(self, adapter):
|
||||
"""A .json snippet should be treated as a text document and injected."""
|
||||
content = b'{"hello": "world", "count": 2}'
|
||||
|
||||
with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl:
|
||||
dl.return_value = content
|
||||
event = self._make_event(
|
||||
text="can you parse this",
|
||||
files=[{
|
||||
"mimetype": "text/plain",
|
||||
"name": "zapfile.json",
|
||||
"filetype": "json",
|
||||
"pretty_type": "JSON",
|
||||
"mode": "snippet",
|
||||
"editable": True,
|
||||
"url_private_download": "https://files.slack.com/zapfile.json",
|
||||
"size": len(content),
|
||||
}],
|
||||
)
|
||||
await adapter._handle_slack_message(event)
|
||||
|
||||
msg_event = adapter.handle_message.call_args[0][0]
|
||||
assert msg_event.message_type == MessageType.DOCUMENT
|
||||
assert len(msg_event.media_urls) == 1
|
||||
assert msg_event.media_types == ["application/json"]
|
||||
assert '[Content of zapfile.json]' in msg_event.text
|
||||
assert '"hello": "world"' in msg_event.text
|
||||
assert 'can you parse this' in msg_event.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_large_txt_not_injected(self, adapter):
|
||||
"""A .txt file over 100KB should be cached but NOT injected."""
|
||||
@@ -498,6 +770,207 @@ 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
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rich_text_blocks_do_not_duplicate_plain_text(self, adapter):
|
||||
"""Plain rich_text composer blocks match the plain text field exactly,
|
||||
so the dedupe guard keeps the message clean."""
|
||||
event = self._make_event(
|
||||
text="hello world",
|
||||
blocks=[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_section",
|
||||
"elements": [
|
||||
{"type": "text", "text": "hello world"},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
await adapter._handle_slack_message(event)
|
||||
|
||||
msg_event = adapter.handle_message.call_args[0][0]
|
||||
assert msg_event.text == "hello world"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rich_text_quotes_and_lists_are_extracted(self, adapter):
|
||||
"""Nested quote and list content should be surfaced from rich_text blocks."""
|
||||
event = self._make_event(
|
||||
text="Can you summarize this?",
|
||||
blocks=[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_quote",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_section",
|
||||
"elements": [{"type": "text", "text": "Quoted line"}],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list",
|
||||
"style": "bullet",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_section",
|
||||
"elements": [{"type": "text", "text": "First bullet"}],
|
||||
},
|
||||
{
|
||||
"type": "rich_text_section",
|
||||
"elements": [{"type": "text", "text": "Second bullet"}],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
await adapter._handle_slack_message(event)
|
||||
|
||||
msg_event = adapter.handle_message.call_args[0][0]
|
||||
assert "Can you summarize this?" in msg_event.text
|
||||
assert "> Quoted line" in msg_event.text
|
||||
assert "• First bullet" in msg_event.text
|
||||
assert "• Second bullet" in msg_event.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attachments_unfurl_text_is_appended_even_when_url_is_in_message(self, adapter):
|
||||
"""Shared URLs should still expose unfurl preview text to the agent."""
|
||||
event = self._make_event(
|
||||
text="Look at this doc https://example.com/spec",
|
||||
attachments=[
|
||||
{
|
||||
"title": "Spec",
|
||||
"from_url": "https://example.com/spec",
|
||||
"text": "The latest product spec preview",
|
||||
"footer": "Notion",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
await adapter._handle_slack_message(event)
|
||||
|
||||
msg_event = adapter.handle_message.call_args[0][0]
|
||||
assert "Look at this doc https://example.com/spec" in msg_event.text
|
||||
assert "📎 [Spec](https://example.com/spec)" in msg_event.text
|
||||
assert "The latest product spec preview" in msg_event.text
|
||||
assert "_Notion_" in msg_event.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_unfurl_attachments_are_skipped(self, adapter):
|
||||
"""Message unfurls should be skipped to avoid echoing Slack message copies."""
|
||||
event = self._make_event(
|
||||
text="https://example.com/thread",
|
||||
attachments=[
|
||||
{
|
||||
"is_msg_unfurl": True,
|
||||
"title": "Thread copy",
|
||||
"text": "This should not be appended",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
await adapter._handle_slack_message(event)
|
||||
|
||||
msg_event = adapter.handle_message.call_args[0][0]
|
||||
assert msg_event.text == "https://example.com/thread"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_routing_ignores_bot_mentions_inside_block_text(self, adapter):
|
||||
"""Block-extracted text with a bot mention must not satisfy mention
|
||||
gating in channels — routing decisions use the original user text so
|
||||
quoted/forwarded content can't trick the bot into responding."""
|
||||
event = self._make_event(
|
||||
text="please review",
|
||||
channel_type="channel",
|
||||
blocks=[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_quote",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_section",
|
||||
"elements": [{"type": "text", "text": "Contains <@U_BOT> in quoted text"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
await adapter._handle_slack_message(event)
|
||||
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quoted_slash_command_text_does_not_change_message_type(self, adapter):
|
||||
"""Quoted slash-like content should not convert a normal message into a command."""
|
||||
event = self._make_event(
|
||||
text="",
|
||||
blocks=[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_quote",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_section",
|
||||
"elements": [{"type": "text", "text": "/deploy now"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
await adapter._handle_slack_message(event)
|
||||
|
||||
msg_event = adapter.handle_message.call_args[0][0]
|
||||
assert msg_event.message_type == MessageType.TEXT
|
||||
assert "> /deploy now" in msg_event.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMessageRouting
|
||||
@@ -1544,6 +2017,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
|
||||
@@ -1797,6 +2347,48 @@ class TestSendImageSSRFGuards:
|
||||
assert "see this" in call_kwargs["text"]
|
||||
assert "https://public.example/image.png" in call_kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_fallback_preserves_thread_metadata(self, adapter):
|
||||
redirect_response = MagicMock()
|
||||
redirect_response.is_redirect = True
|
||||
redirect_response.next_request = MagicMock(
|
||||
url="http://169.254.169.254/latest/meta-data"
|
||||
)
|
||||
|
||||
client_kwargs = {}
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
async def fake_get(_url):
|
||||
for hook in client_kwargs["event_hooks"]["response"]:
|
||||
await hook(redirect_response)
|
||||
|
||||
mock_client.get = AsyncMock(side_effect=fake_get)
|
||||
adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True})
|
||||
adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "reply_ts"})
|
||||
|
||||
def fake_async_client(*args, **kwargs):
|
||||
client_kwargs.update(kwargs)
|
||||
return mock_client
|
||||
|
||||
def fake_is_safe_url(url):
|
||||
return url == "https://public.example/image.png"
|
||||
|
||||
with (
|
||||
patch("tools.url_safety.is_safe_url", side_effect=fake_is_safe_url),
|
||||
patch("httpx.AsyncClient", side_effect=fake_async_client),
|
||||
):
|
||||
await adapter.send_image(
|
||||
chat_id="C123",
|
||||
image_url="https://public.example/image.png",
|
||||
caption="see this",
|
||||
metadata={"thread_id": "parent_ts_789"},
|
||||
)
|
||||
|
||||
call_kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
|
||||
assert call_kwargs.get("thread_ts") == "parent_ts_789"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestProgressMessageThread
|
||||
@@ -1921,3 +2513,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)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Tests for Slack channel_skill_bindings auto-skill resolution."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def _make_adapter(extra=None):
|
||||
"""Create a minimal SlackAdapter stub with the given ``config.extra``."""
|
||||
from gateway.platforms.slack import SlackAdapter
|
||||
adapter = object.__new__(SlackAdapter)
|
||||
adapter.config = MagicMock()
|
||||
adapter.config.extra = extra or {}
|
||||
return adapter
|
||||
|
||||
|
||||
def _resolve(adapter, channel_id, parent_id=None):
|
||||
from gateway.platforms.base import resolve_channel_skills
|
||||
return resolve_channel_skills(adapter.config.extra, channel_id, parent_id)
|
||||
|
||||
|
||||
class TestSlackResolveChannelSkills:
|
||||
def test_no_bindings_returns_none(self):
|
||||
adapter = _make_adapter()
|
||||
assert _resolve(adapter, "D0ABC") is None
|
||||
|
||||
def test_match_by_dm_channel_id(self):
|
||||
"""The primary use case: binding a skill to a Slack DM channel."""
|
||||
adapter = _make_adapter({
|
||||
"channel_skill_bindings": [
|
||||
{"id": "D0ATH9TQ0G6", "skills": ["german-flashcards"]},
|
||||
]
|
||||
})
|
||||
assert _resolve(adapter, "D0ATH9TQ0G6") == ["german-flashcards"]
|
||||
|
||||
def test_match_by_parent_id_for_thread(self):
|
||||
"""Slack threads inherit the parent channel's binding."""
|
||||
adapter = _make_adapter({
|
||||
"channel_skill_bindings": [
|
||||
{"id": "C0PARENT", "skills": ["parent-skill"]},
|
||||
]
|
||||
})
|
||||
assert _resolve(adapter, "thread-ts-123", parent_id="C0PARENT") == ["parent-skill"]
|
||||
|
||||
def test_no_match_returns_none(self):
|
||||
adapter = _make_adapter({
|
||||
"channel_skill_bindings": [
|
||||
{"id": "D0AAA", "skills": ["skill-a"]},
|
||||
]
|
||||
})
|
||||
assert _resolve(adapter, "D0BBB") is None
|
||||
|
||||
def test_single_skill_string(self):
|
||||
adapter = _make_adapter({
|
||||
"channel_skill_bindings": [
|
||||
{"id": "D0ATH9TQ0G6", "skill": "german-flashcards"},
|
||||
]
|
||||
})
|
||||
assert _resolve(adapter, "D0ATH9TQ0G6") == ["german-flashcards"]
|
||||
|
||||
def test_dedup_preserves_order(self):
|
||||
adapter = _make_adapter({
|
||||
"channel_skill_bindings": [
|
||||
{"id": "D0ATH9TQ0G6", "skills": ["a", "b", "a", "c", "b"]},
|
||||
]
|
||||
})
|
||||
assert _resolve(adapter, "D0ATH9TQ0G6") == ["a", "b", "c"]
|
||||
|
||||
def test_multiple_bindings_pick_correct(self):
|
||||
adapter = _make_adapter({
|
||||
"channel_skill_bindings": [
|
||||
{"id": "D0AAA", "skills": ["skill-a"]},
|
||||
{"id": "D0BBB", "skills": ["skill-b"]},
|
||||
{"id": "D0CCC", "skills": ["skill-c"]},
|
||||
]
|
||||
})
|
||||
assert _resolve(adapter, "D0BBB") == ["skill-b"]
|
||||
|
||||
def test_malformed_entry_skipped(self):
|
||||
"""Non-dict entries should be ignored, not raise."""
|
||||
adapter = _make_adapter({
|
||||
"channel_skill_bindings": [
|
||||
"not-a-dict",
|
||||
{"id": "D0ABC", "skills": ["good"]},
|
||||
]
|
||||
})
|
||||
assert _resolve(adapter, "D0ABC") == ["good"]
|
||||
|
||||
def test_empty_skills_list_returns_none(self):
|
||||
adapter = _make_adapter({
|
||||
"channel_skill_bindings": [
|
||||
{"id": "D0ABC", "skills": []},
|
||||
]
|
||||
})
|
||||
assert _resolve(adapter, "D0ABC") is None
|
||||
|
||||
def test_empty_skill_string_returns_none(self):
|
||||
adapter = _make_adapter({
|
||||
"channel_skill_bindings": [
|
||||
{"id": "D0ABC", "skill": ""},
|
||||
]
|
||||
})
|
||||
assert _resolve(adapter, "D0ABC") is None
|
||||
|
||||
|
||||
class TestSlackMessageEventAutoSkill:
|
||||
"""Integration-style test: verify auto_skill propagates to MessageEvent."""
|
||||
|
||||
def test_message_event_carries_auto_skill(self):
|
||||
"""Simulate the handler wiring: resolve + attach to MessageEvent."""
|
||||
from gateway.platforms.base import MessageEvent, MessageType, Platform, SessionSource, resolve_channel_skills
|
||||
|
||||
config_extra = {
|
||||
"channel_skill_bindings": [
|
||||
{"id": "D0ATH9TQ0G6", "skills": ["german-flashcards"]},
|
||||
]
|
||||
}
|
||||
auto_skill = resolve_channel_skills(config_extra, "D0ATH9TQ0G6", None)
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.SLACK,
|
||||
chat_id="D0ATH9TQ0G6",
|
||||
chat_name="Mats",
|
||||
chat_type="dm",
|
||||
user_id="U0ABC",
|
||||
user_name="Mats",
|
||||
)
|
||||
event = MessageEvent(
|
||||
text="work",
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
raw_message={},
|
||||
message_id="123.456",
|
||||
auto_skill=auto_skill,
|
||||
)
|
||||
assert event.auto_skill == ["german-flashcards"]
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Regression tests for the fresh-final-for-long-lived-previews path.
|
||||
|
||||
Ported from openclaw/openclaw#72038. When a streamed preview has been
|
||||
visible long enough that the platform's edit timestamp would be
|
||||
noticeably stale by completion time, the stream consumer delivers the
|
||||
final reply as a brand-new message and best-effort deletes the old
|
||||
preview. This makes Telegram's visible timestamp reflect completion
|
||||
time instead of first-token time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig
|
||||
|
||||
|
||||
def _make_adapter(*, supports_delete: bool = True) -> MagicMock:
|
||||
"""Build a minimal MagicMock adapter wired for send/edit/delete."""
|
||||
adapter = MagicMock()
|
||||
adapter.REQUIRES_EDIT_FINALIZE = False
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
adapter.send = AsyncMock(return_value=SimpleNamespace(
|
||||
success=True, message_id="initial_preview",
|
||||
))
|
||||
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(
|
||||
success=True, message_id="initial_preview",
|
||||
))
|
||||
if supports_delete:
|
||||
adapter.delete_message = AsyncMock(return_value=True)
|
||||
else:
|
||||
# Adapter without the optional delete_message method — fresh-final
|
||||
# should still work, it just leaves the stale preview in place.
|
||||
del adapter.delete_message # type: ignore[attr-defined]
|
||||
return adapter
|
||||
|
||||
|
||||
class TestFreshFinalForLongLivedPreviews:
|
||||
"""openclaw#72038 port — send fresh final when preview is old."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_by_default_still_edits_in_place(self):
|
||||
"""``fresh_final_after_seconds=0`` preserves the legacy edit path."""
|
||||
adapter = _make_adapter()
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(fresh_final_after_seconds=0.0),
|
||||
)
|
||||
await consumer._send_or_edit("hello")
|
||||
# Pretend the preview has been visible for a long time.
|
||||
consumer._message_created_ts = 0.0 # far in the past
|
||||
await consumer._send_or_edit("hello world", finalize=True)
|
||||
# Should edit, not send a fresh message.
|
||||
assert adapter.send.call_count == 1 # only the initial send
|
||||
adapter.edit_message.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_lived_preview_edits_in_place(self):
|
||||
"""Finalizing a preview younger than the threshold → normal edit."""
|
||||
adapter = _make_adapter()
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(fresh_final_after_seconds=60.0),
|
||||
)
|
||||
await consumer._send_or_edit("hello")
|
||||
# Preview is "new" — leave _message_created_ts at its real value.
|
||||
await consumer._send_or_edit("hello world", finalize=True)
|
||||
assert adapter.send.call_count == 1
|
||||
adapter.edit_message.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_lived_preview_sends_fresh_final(self):
|
||||
"""Finalizing a preview older than the threshold → fresh send."""
|
||||
adapter = _make_adapter()
|
||||
adapter.send.side_effect = [
|
||||
SimpleNamespace(success=True, message_id="initial_preview"),
|
||||
SimpleNamespace(success=True, message_id="fresh_final"),
|
||||
]
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(fresh_final_after_seconds=60.0),
|
||||
)
|
||||
await consumer._send_or_edit("hello")
|
||||
# Force the preview to look stale (visible for > 60s).
|
||||
consumer._message_created_ts = 0.0 # zero = ~uptime seconds old
|
||||
await consumer._send_or_edit("hello world", finalize=True)
|
||||
# Fresh send happened; no edit of the old preview.
|
||||
assert adapter.send.call_count == 2
|
||||
adapter.edit_message.assert_not_called()
|
||||
# The old preview was deleted as cleanup.
|
||||
adapter.delete_message.assert_awaited_once_with("chat", "initial_preview")
|
||||
# State was updated to the new message id.
|
||||
assert consumer._message_id == "fresh_final"
|
||||
assert consumer._final_response_sent is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fresh_final_without_delete_support_is_best_effort(self):
|
||||
"""Adapter lacking ``delete_message`` still gets the fresh send."""
|
||||
adapter = _make_adapter(supports_delete=False)
|
||||
adapter.send.side_effect = [
|
||||
SimpleNamespace(success=True, message_id="initial_preview"),
|
||||
SimpleNamespace(success=True, message_id="fresh_final"),
|
||||
]
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(fresh_final_after_seconds=60.0),
|
||||
)
|
||||
await consumer._send_or_edit("hello")
|
||||
consumer._message_created_ts = 0.0
|
||||
await consumer._send_or_edit("hello world", finalize=True)
|
||||
assert adapter.send.call_count == 2
|
||||
adapter.edit_message.assert_not_called()
|
||||
# No delete attempt — just the fresh send.
|
||||
assert consumer._message_id == "fresh_final"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fresh_final_fallback_to_edit_on_send_failure(self):
|
||||
"""If the fresh send fails, fall back to the normal edit path."""
|
||||
adapter = _make_adapter()
|
||||
adapter.send.side_effect = [
|
||||
SimpleNamespace(success=True, message_id="initial_preview"),
|
||||
SimpleNamespace(success=False, error="network"),
|
||||
]
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(fresh_final_after_seconds=60.0),
|
||||
)
|
||||
await consumer._send_or_edit("hello")
|
||||
consumer._message_created_ts = 0.0
|
||||
ok = await consumer._send_or_edit("hello world", finalize=True)
|
||||
# Fresh send was attempted and failed → edit happened instead.
|
||||
assert adapter.send.call_count == 2
|
||||
adapter.edit_message.assert_called_once()
|
||||
assert ok is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_finalize_triggers_fresh_final(self):
|
||||
"""Intermediate edits (``finalize=False``) never switch to fresh send."""
|
||||
adapter = _make_adapter()
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(fresh_final_after_seconds=60.0),
|
||||
)
|
||||
await consumer._send_or_edit("hello")
|
||||
consumer._message_created_ts = 0.0 # stale
|
||||
await consumer._send_or_edit("hello partial") # no finalize
|
||||
assert adapter.send.call_count == 1
|
||||
adapter.edit_message.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_edit_sentinel_is_not_affected(self):
|
||||
"""Platforms with the ``__no_edit__`` sentinel never go fresh-final."""
|
||||
adapter = _make_adapter()
|
||||
adapter.send.return_value = SimpleNamespace(success=True, message_id=None)
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(fresh_final_after_seconds=60.0),
|
||||
)
|
||||
await consumer._send_or_edit("hello")
|
||||
assert consumer._message_id == "__no_edit__"
|
||||
assert consumer._message_created_ts is None
|
||||
# Even with finalize=True, no fresh send — the sentinel gates it.
|
||||
assert consumer._should_send_fresh_final() is False
|
||||
|
||||
|
||||
class TestStreamConsumerConfigFreshFinalField:
|
||||
"""The dataclass field must exist and default to 0 (disabled)."""
|
||||
|
||||
def test_default_is_disabled(self):
|
||||
cfg = StreamConsumerConfig()
|
||||
assert cfg.fresh_final_after_seconds == 0.0
|
||||
|
||||
def test_field_is_configurable(self):
|
||||
cfg = StreamConsumerConfig(fresh_final_after_seconds=120.0)
|
||||
assert cfg.fresh_final_after_seconds == 120.0
|
||||
|
||||
|
||||
class TestStreamingConfigFreshFinalField:
|
||||
"""The gateway-level StreamingConfig carries the setting."""
|
||||
|
||||
def test_default_enables_with_60s(self):
|
||||
from gateway.config import StreamingConfig
|
||||
cfg = StreamingConfig()
|
||||
assert cfg.fresh_final_after_seconds == 60.0
|
||||
|
||||
def test_from_dict_uses_default_when_missing(self):
|
||||
from gateway.config import StreamingConfig
|
||||
cfg = StreamingConfig.from_dict({"enabled": True})
|
||||
assert cfg.fresh_final_after_seconds == 60.0
|
||||
|
||||
def test_from_dict_respects_explicit_zero(self):
|
||||
from gateway.config import StreamingConfig
|
||||
cfg = StreamingConfig.from_dict({
|
||||
"enabled": True,
|
||||
"fresh_final_after_seconds": 0,
|
||||
})
|
||||
assert cfg.fresh_final_after_seconds == 0.0
|
||||
|
||||
def test_to_dict_round_trip(self):
|
||||
from gateway.config import StreamingConfig
|
||||
original = StreamingConfig(fresh_final_after_seconds=90.0)
|
||||
restored = StreamingConfig.from_dict(original.to_dict())
|
||||
assert restored.fresh_final_after_seconds == 90.0
|
||||
|
||||
|
||||
class TestTelegramAdapterDeleteMessage:
|
||||
"""Contract: Telegram adapter implements ``delete_message``."""
|
||||
|
||||
def test_delete_message_method_exists(self):
|
||||
telegram = pytest.importorskip("gateway.platforms.telegram")
|
||||
import inspect
|
||||
cls = telegram.TelegramAdapter
|
||||
assert hasattr(cls, "delete_message"), (
|
||||
"TelegramAdapter.delete_message is required for the fresh-final "
|
||||
"cleanup path (openclaw/openclaw#72038 port)."
|
||||
)
|
||||
sig = inspect.signature(cls.delete_message)
|
||||
params = list(sig.parameters)
|
||||
assert params[:3] == ["self", "chat_id", "message_id"]
|
||||
|
||||
def test_base_adapter_default_returns_false(self):
|
||||
"""BasePlatformAdapter.delete_message default = no-op returning False."""
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
import inspect
|
||||
sig = inspect.signature(BasePlatformAdapter.delete_message)
|
||||
assert list(sig.parameters)[:3] == ["self", "chat_id", "message_id"]
|
||||
@@ -251,7 +251,7 @@ class TestWatchUpdateProgress:
|
||||
"session_key": "agent:main:telegram:dm:111"}
|
||||
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
|
||||
# Write output
|
||||
(hermes_home / ".update_output.txt").write_text("→ Fetching updates...\n")
|
||||
(hermes_home / ".update_output.txt").write_text("→ Fetching updates...\n", encoding="utf-8")
|
||||
|
||||
mock_adapter = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
||||
@@ -261,7 +261,7 @@ class TestWatchUpdateProgress:
|
||||
await asyncio.sleep(0.3)
|
||||
(hermes_home / ".update_output.txt").write_text(
|
||||
"→ Fetching updates...\n✓ Code updated!\n"
|
||||
)
|
||||
, encoding="utf-8")
|
||||
(hermes_home / ".update_exit_code").write_text("0")
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
@@ -489,6 +489,63 @@ class TestUpdatePromptInterception:
|
||||
# Should clear the pending flag
|
||||
assert session_key not in runner._update_prompt_pending
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recognized_slash_command_bypasses_pending_update_prompt(self, tmp_path):
|
||||
"""Known slash commands must dispatch normally instead of being consumed.
|
||||
|
||||
The update subprocess is still blocked on stdin waiting for
|
||||
``.update_response``, so the gateway writes a blank response to
|
||||
unblock it (``_gateway_prompt`` returns the prompt's default on
|
||||
empty) before falling through to normal command dispatch.
|
||||
"""
|
||||
runner = _make_runner()
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
event = _make_event(text="/new", chat_id="67890")
|
||||
session_key = "agent:main:telegram:dm:67890"
|
||||
runner._update_prompt_pending[session_key] = True
|
||||
runner._is_user_authorized = MagicMock(return_value=True)
|
||||
runner._session_key_for_source = MagicMock(return_value=session_key)
|
||||
runner._handle_reset_command = AsyncMock(return_value="reset ok")
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
result = await runner._handle_message(event)
|
||||
|
||||
assert result == "reset ok"
|
||||
runner._handle_reset_command.assert_awaited_once_with(event)
|
||||
# .update_response was written (empty) to unblock the update
|
||||
# subprocess; _gateway_prompt will read "", strip to "", and
|
||||
# return the prompt's default.
|
||||
response_path = hermes_home / ".update_response"
|
||||
assert response_path.exists()
|
||||
assert response_path.read_text() == ""
|
||||
# Pending flag is cleared so stray future input won't be
|
||||
# re-intercepted for a prompt that is no longer outstanding.
|
||||
assert session_key not in runner._update_prompt_pending
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unrecognized_slash_command_still_consumed_as_response(self, tmp_path):
|
||||
"""Unknown /foo is written verbatim to .update_response (legacy behavior)."""
|
||||
runner = _make_runner()
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
event = _make_event(text="/foobarbaz", chat_id="67890")
|
||||
session_key = "agent:main:telegram:dm:67890"
|
||||
runner._update_prompt_pending[session_key] = True
|
||||
runner._is_user_authorized = MagicMock(return_value=True)
|
||||
runner._session_key_for_source = MagicMock(return_value=session_key)
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
result = await runner._handle_message(event)
|
||||
|
||||
response_path = hermes_home / ".update_response"
|
||||
assert response_path.exists()
|
||||
assert response_path.read_text() == "/foobarbaz"
|
||||
assert "Sent" in (result or "")
|
||||
assert session_key not in runner._update_prompt_pending
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_message_when_no_prompt_pending(self, tmp_path):
|
||||
"""Messages pass through normally when no prompt is pending."""
|
||||
|
||||
@@ -134,7 +134,7 @@ class TestVerboseCommand:
|
||||
"""Cycling /verbose on Telegram doesn't change Slack's setting.
|
||||
|
||||
Without a global tool_progress, each platform uses its built-in
|
||||
default: Telegram = 'all' (high tier), Slack = 'new' (medium tier).
|
||||
default: Telegram = 'all' (high tier), Slack = 'off' (quiet Slack default).
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
@@ -161,8 +161,8 @@ class TestVerboseCommand:
|
||||
platforms = saved["display"]["platforms"]
|
||||
# Telegram: all -> verbose (high tier default = all)
|
||||
assert platforms["telegram"]["tool_progress"] == "verbose"
|
||||
# Slack: new -> all (medium tier default = new, cycle to all)
|
||||
assert platforms["slack"]["tool_progress"] == "all"
|
||||
# Slack: off -> new (first /verbose cycle from quiet default)
|
||||
assert platforms["slack"]["tool_progress"] == "new"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_config_file_returns_disabled(self, tmp_path, monkeypatch):
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -149,3 +149,46 @@ def test_get_nous_subscription_features_requires_agent_browser_for_browserbase(m
|
||||
assert features.browser.active is False
|
||||
assert features.browser.managed_by_nous is False
|
||||
assert features.browser.current_provider == "Browserbase"
|
||||
|
||||
|
||||
def test_get_nous_subscription_features_does_not_treat_quoted_false_as_gateway_opt_in(monkeypatch):
|
||||
env = {"EXA_API_KEY": "exa-test"}
|
||||
|
||||
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
|
||||
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
|
||||
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
|
||||
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "web")
|
||||
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: vendor == "firecrawl")
|
||||
|
||||
features = ns.get_nous_subscription_features(
|
||||
{"web": {"backend": "exa", "use_gateway": "false"}}
|
||||
)
|
||||
|
||||
assert features.web.available is True
|
||||
assert features.web.active is True
|
||||
assert features.web.managed_by_nous is False
|
||||
assert features.web.direct_override is True
|
||||
assert features.web.current_provider == "exa"
|
||||
|
||||
|
||||
def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
|
||||
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
ns,
|
||||
"_get_gateway_direct_credentials",
|
||||
lambda: {"web": True, "image_gen": False, "tts": False, "browser": False},
|
||||
)
|
||||
|
||||
unconfigured, has_direct, already_managed = ns.get_gateway_eligible_tools(
|
||||
{
|
||||
"model": {"provider": "nous"},
|
||||
"web": {"use_gateway": "false"},
|
||||
}
|
||||
)
|
||||
|
||||
assert "web" in has_direct
|
||||
assert "web" not in already_managed
|
||||
assert set(unconfigured) == {"image_gen", "tts", "browser"}
|
||||
|
||||
@@ -401,14 +401,21 @@ class TestSessionBrowseArgparse:
|
||||
from hermes_cli.main import _session_browse_picker
|
||||
assert callable(_session_browse_picker)
|
||||
|
||||
def test_browse_default_limit_is_50(self):
|
||||
"""The default --limit for browse should be 50."""
|
||||
# This test verifies at the argparse level
|
||||
# We test by running the parse on "sessions browse" args
|
||||
# Since we can't easily extract the subparser, verify via the
|
||||
# _session_browse_picker accepting large lists
|
||||
sessions = _make_sessions(50)
|
||||
assert len(sessions) == 50
|
||||
def test_browse_default_limit_is_500(self):
|
||||
"""The default --limit for browse should be 500."""
|
||||
# Build the same argparse tree cmd_sessions uses and verify the default.
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers(dest="sessions_action")
|
||||
browse = subparsers.add_parser("browse")
|
||||
browse.add_argument("--source")
|
||||
browse.add_argument("--limit", type=int, default=500)
|
||||
|
||||
args = parser.parse_args(["browse"])
|
||||
assert args.limit == 500
|
||||
|
||||
args = parser.parse_args(["browse", "--limit", "42"])
|
||||
assert args.limit == 42
|
||||
|
||||
|
||||
# ─── Integration: cmd_sessions browse action ────────────────────────────────
|
||||
|
||||
@@ -12,7 +12,7 @@ def test_sessions_delete_accepts_unique_id_prefix(monkeypatch, capsys):
|
||||
captured["resolved_from"] = session_id
|
||||
return "20260315_092437_c9a6ff"
|
||||
|
||||
def delete_session(self, session_id):
|
||||
def delete_session(self, session_id, **kwargs):
|
||||
captured["deleted"] = session_id
|
||||
return True
|
||||
|
||||
@@ -45,7 +45,7 @@ def test_sessions_delete_reports_not_found_when_prefix_is_unknown(monkeypatch, c
|
||||
def resolve_session_id(self, session_id):
|
||||
return None
|
||||
|
||||
def delete_session(self, session_id):
|
||||
def delete_session(self, session_id, **kwargs):
|
||||
raise AssertionError("delete_session should not be called when resolution fails")
|
||||
|
||||
def close(self):
|
||||
@@ -73,7 +73,7 @@ def test_sessions_delete_handles_eoferror_on_confirm(monkeypatch, capsys):
|
||||
def resolve_session_id(self, session_id):
|
||||
return "20260315_092437_c9a6ff"
|
||||
|
||||
def delete_session(self, session_id):
|
||||
def delete_session(self, session_id, **kwargs):
|
||||
raise AssertionError("delete_session should not be called when cancelled")
|
||||
|
||||
def close(self):
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Regression: ``hermes setup`` for the ollama-cloud provider must force-refresh
|
||||
the model cache after the user supplies a key, otherwise the picker keeps
|
||||
serving a stale cache (models.dev only, no live API probe) for up to an hour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_setup_ollama_cloud_passes_force_refresh(monkeypatch):
|
||||
"""The provider-setup model-fetch for ollama-cloud must pass ``force_refresh=True``."""
|
||||
import hermes_cli.main as main_mod
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(main_mod)
|
||||
|
||||
# Locate the ollama-cloud branch in the provider setup flow.
|
||||
marker = 'provider_id == "ollama-cloud"'
|
||||
assert marker in src, "ollama-cloud branch missing from provider setup"
|
||||
idx = src.index(marker)
|
||||
# The call to fetch_ollama_cloud_models should be within the next ~2000 chars.
|
||||
snippet = src[idx:idx + 2000]
|
||||
assert "fetch_ollama_cloud_models(" in snippet, snippet[:500]
|
||||
assert "force_refresh=True" in snippet, (
|
||||
"ollama-cloud setup must pass force_refresh=True so newly released "
|
||||
"models (e.g. deepseek v4 flash, kimi k2.6) appear the moment the "
|
||||
"user enters their key, not an hour later when the cache TTL expires. "
|
||||
f"Snippet: {snippet[:500]}"
|
||||
)
|
||||
@@ -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"},
|
||||
@@ -248,3 +316,211 @@ def test_do_install_scans_with_resolved_identifier(monkeypatch, tmp_path, hub_en
|
||||
do_install("skils-sh/anthropics/skills/frontend-design", console=console, skip_confirm=True)
|
||||
|
||||
assert scanned["source"] == canonical_identifier
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UrlSource-specific install paths: --name override, interactive prompts,
|
||||
# non-interactive error, existing-category scan.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_url_bundle_fetcher(name="", awaiting_name=True, url="https://example.com/SKILL.md"):
|
||||
"""Return a fake source that simulates ``UrlSource.fetch`` for a
|
||||
URL-sourced skill whose name hasn't been auto-resolved."""
|
||||
|
||||
class _UrlSource:
|
||||
def inspect(self, identifier):
|
||||
return type("Meta", (), {
|
||||
"extra": {"url": url, "awaiting_name": awaiting_name},
|
||||
"identifier": url,
|
||||
"name": name,
|
||||
"path": name,
|
||||
})()
|
||||
|
||||
def fetch(self, identifier):
|
||||
return type("Bundle", (), {
|
||||
"name": name,
|
||||
"files": {"SKILL.md": "---\ndescription: ok\n---\n# body\n"},
|
||||
"source": "url",
|
||||
"identifier": url,
|
||||
"trust_level": "community",
|
||||
"metadata": {"url": url, "awaiting_name": awaiting_name},
|
||||
})()
|
||||
|
||||
return _UrlSource
|
||||
|
||||
|
||||
def _install_mocks(monkeypatch, tmp_path, source_factory, category_hint=""):
|
||||
"""Wire the minimum set of monkeypatches for a do_install dry run."""
|
||||
import tools.skills_hub as hub
|
||||
import tools.skills_guard as guard
|
||||
|
||||
q_path = tmp_path / "skills" / ".hub" / "quarantine" / "pending"
|
||||
q_path.mkdir(parents=True)
|
||||
|
||||
install_calls: list = []
|
||||
|
||||
def _install_from_quarantine(q, name, category, bundle, result):
|
||||
install_calls.append({"name": name, "category": category})
|
||||
install_dir = tmp_path / "skills" / (f"{category}/" if category else "") / name
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
return install_dir
|
||||
|
||||
monkeypatch.setattr(hub, "ensure_hub_dirs", lambda: None)
|
||||
monkeypatch.setattr(hub, "create_source_router", lambda auth: [source_factory()])
|
||||
monkeypatch.setattr(hub, "quarantine_bundle", lambda bundle: q_path)
|
||||
monkeypatch.setattr(hub, "install_from_quarantine", _install_from_quarantine)
|
||||
monkeypatch.setattr(
|
||||
hub, "HubLockFile",
|
||||
lambda: type("Lock", (), {"get_installed": lambda self, n: None})(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
guard, "scan_skill",
|
||||
lambda skill_path, source="community": guard.ScanResult(
|
||||
skill_name="pending", source=source, trust_level="community", verdict="safe",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(guard, "format_scan_report", lambda result: "scan ok")
|
||||
monkeypatch.setattr(guard, "should_allow_install", lambda result, force=False: (True, "ok"))
|
||||
return install_calls
|
||||
|
||||
|
||||
def test_url_install_uses_name_override_on_non_interactive_surface(monkeypatch, tmp_path, hub_env):
|
||||
installs = _install_mocks(monkeypatch, tmp_path, _make_url_bundle_fetcher())
|
||||
|
||||
sink = StringIO()
|
||||
console = Console(file=sink, force_terminal=False, color_system=None)
|
||||
do_install(
|
||||
"https://example.com/SKILL.md",
|
||||
console=console, skip_confirm=True,
|
||||
name_override="my-url-skill",
|
||||
)
|
||||
|
||||
assert installs == [{"name": "my-url-skill", "category": ""}]
|
||||
|
||||
|
||||
def test_url_install_rejects_invalid_name_override(monkeypatch, tmp_path, hub_env):
|
||||
installs = _install_mocks(monkeypatch, tmp_path, _make_url_bundle_fetcher())
|
||||
|
||||
sink = StringIO()
|
||||
console = Console(file=sink, force_terminal=False, color_system=None)
|
||||
do_install(
|
||||
"https://example.com/SKILL.md",
|
||||
console=console, skip_confirm=True,
|
||||
name_override="SKILL", # rejected by _is_valid_installed_skill_name
|
||||
)
|
||||
|
||||
assert installs == [] # did NOT install
|
||||
assert "Invalid --name" in sink.getvalue()
|
||||
|
||||
|
||||
def test_url_install_actionable_error_on_non_interactive_with_no_name(monkeypatch, tmp_path, hub_env):
|
||||
installs = _install_mocks(monkeypatch, tmp_path, _make_url_bundle_fetcher())
|
||||
|
||||
sink = StringIO()
|
||||
console = Console(file=sink, force_terminal=False, color_system=None)
|
||||
do_install(
|
||||
"https://example.com/SKILL.md",
|
||||
console=console, skip_confirm=True,
|
||||
# No name_override — should error out with a retry hint.
|
||||
)
|
||||
|
||||
assert installs == []
|
||||
out = sink.getvalue()
|
||||
assert "Cannot install from URL" in out
|
||||
assert "--name <your-name>" in out
|
||||
|
||||
|
||||
def test_url_install_prompts_interactively_when_tty(monkeypatch, tmp_path, hub_env):
|
||||
installs = _install_mocks(monkeypatch, tmp_path, _make_url_bundle_fetcher())
|
||||
|
||||
# Simulate user typing "my-interactive" to name prompt, then "" to category.
|
||||
answers = iter(["my-interactive", ""])
|
||||
monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
|
||||
|
||||
sink = StringIO()
|
||||
console = Console(file=sink, force_terminal=False, color_system=None)
|
||||
do_install(
|
||||
"https://example.com/SKILL.md",
|
||||
console=console, skip_confirm=False, # interactive
|
||||
force=True, # skip the final confirm prompt (tested elsewhere)
|
||||
)
|
||||
|
||||
assert installs == [{"name": "my-interactive", "category": ""}]
|
||||
|
||||
|
||||
def test_url_install_prompts_category_and_uses_typed_value(monkeypatch, tmp_path, hub_env):
|
||||
import tools.skills_hub as hub
|
||||
installs = _install_mocks(
|
||||
monkeypatch, tmp_path,
|
||||
_make_url_bundle_fetcher(name="sharethis-chat", awaiting_name=False),
|
||||
)
|
||||
|
||||
# Stage an existing category bucket so _existing_categories finds it.
|
||||
(hub.SKILLS_DIR / "productivity" / "notion").mkdir(parents=True)
|
||||
(hub.SKILLS_DIR / "productivity" / "notion" / "SKILL.md").write_text("# notion")
|
||||
|
||||
# Name is already resolved (from frontmatter) → only category prompt fires.
|
||||
answers = iter(["productivity"])
|
||||
monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
|
||||
|
||||
sink = StringIO()
|
||||
console = Console(file=sink, force_terminal=False, color_system=None)
|
||||
do_install(
|
||||
"https://example.com/sharethis-chat/SKILL.md",
|
||||
console=console, skip_confirm=False, force=True,
|
||||
)
|
||||
|
||||
assert installs == [{"name": "sharethis-chat", "category": "productivity"}]
|
||||
assert "Existing: productivity" in sink.getvalue()
|
||||
|
||||
|
||||
def test_url_install_cancel_name_prompt_aborts(monkeypatch, tmp_path, hub_env):
|
||||
installs = _install_mocks(monkeypatch, tmp_path, _make_url_bundle_fetcher())
|
||||
|
||||
# Empty input with no default → name prompt returns None → abort.
|
||||
monkeypatch.setattr("builtins.input", lambda prompt="": "")
|
||||
|
||||
sink = StringIO()
|
||||
console = Console(file=sink, force_terminal=False, color_system=None)
|
||||
do_install(
|
||||
"https://example.com/SKILL.md",
|
||||
console=console, skip_confirm=False, force=True,
|
||||
)
|
||||
|
||||
assert installs == []
|
||||
assert "Installation cancelled" in sink.getvalue()
|
||||
|
||||
|
||||
# ── _existing_categories ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_existing_categories_skips_top_level_skills(monkeypatch, tmp_path, hub_env):
|
||||
import tools.skills_hub as hub
|
||||
from hermes_cli.skills_hub import _existing_categories
|
||||
|
||||
# Category bucket with nested skill.
|
||||
(hub.SKILLS_DIR / "productivity" / "notion").mkdir(parents=True)
|
||||
(hub.SKILLS_DIR / "productivity" / "notion" / "SKILL.md").write_text("# notion")
|
||||
|
||||
# Flat skill at top level (NOT a category).
|
||||
(hub.SKILLS_DIR / "my-flat-skill").mkdir()
|
||||
(hub.SKILLS_DIR / "my-flat-skill" / "SKILL.md").write_text("# flat")
|
||||
|
||||
# Empty dir (NOT a category — no SKILL.md below).
|
||||
(hub.SKILLS_DIR / "empty-dir").mkdir()
|
||||
|
||||
# Hidden dir (ignored).
|
||||
(hub.SKILLS_DIR / ".hub").mkdir(exist_ok=True)
|
||||
|
||||
cats = _existing_categories()
|
||||
assert cats == ["productivity"]
|
||||
|
||||
|
||||
def test_existing_categories_returns_empty_when_skills_dir_missing(monkeypatch, tmp_path, hub_env):
|
||||
# hub_env creates tmp_path/skills/.hub — we point SKILLS_DIR at a missing sibling.
|
||||
import tools.skills_hub as hub
|
||||
monkeypatch.setattr(hub, "SKILLS_DIR", tmp_path / "does-not-exist")
|
||||
|
||||
from hermes_cli.skills_hub import _existing_categories
|
||||
assert _existing_categories() == []
|
||||
|
||||
@@ -41,6 +41,36 @@ def test_get_platform_tools_homeassistant_platform_keeps_homeassistant_toolset()
|
||||
assert "homeassistant" in enabled
|
||||
|
||||
|
||||
def test_get_platform_tools_homeassistant_toolset_enabled_for_cron_when_hass_token_set(monkeypatch):
|
||||
"""HA toolset is runtime-gated by check_fn (requires HASS_TOKEN).
|
||||
|
||||
When HASS_TOKEN is set, the user has explicitly opted in — _DEFAULT_OFF_TOOLSETS
|
||||
shouldn't also strip HA from platforms (like cron) that run through
|
||||
_get_platform_tools without an explicit saved toolset list.
|
||||
|
||||
Regression guard for Norbert's HA cron breakage after #14798 made cron
|
||||
honor per-platform tool config.
|
||||
"""
|
||||
monkeypatch.setenv("HASS_TOKEN", "fake-test-token")
|
||||
|
||||
cron_enabled = _get_platform_tools({}, "cron")
|
||||
assert "homeassistant" in cron_enabled
|
||||
# moa must stay off — the original goal of #14798
|
||||
assert "moa" not in cron_enabled
|
||||
|
||||
cli_enabled = _get_platform_tools({}, "cli")
|
||||
assert "homeassistant" in cli_enabled
|
||||
|
||||
|
||||
def test_get_platform_tools_homeassistant_toolset_off_for_cron_when_hass_token_missing(monkeypatch):
|
||||
"""Without HASS_TOKEN, HA stays off by default — preserves #14798's behavior
|
||||
for users who never configured HA."""
|
||||
monkeypatch.delenv("HASS_TOKEN", raising=False)
|
||||
|
||||
cron_enabled = _get_platform_tools({}, "cron")
|
||||
assert "homeassistant" not in cron_enabled
|
||||
|
||||
|
||||
def test_get_platform_tools_preserves_explicit_empty_selection():
|
||||
config = {"platform_toolsets": {"cli": []}}
|
||||
|
||||
|
||||
@@ -145,6 +145,7 @@ def test_launch_tui_exports_model_and_provider(monkeypatch, main_mod):
|
||||
assert env["HERMES_INFERENCE_MODEL"] == "nous/hermes-test"
|
||||
assert env["HERMES_TUI_PROVIDER"] == "nous"
|
||||
assert env["HERMES_INFERENCE_PROVIDER"] == "nous"
|
||||
assert env["NODE_ENV"] == "production"
|
||||
|
||||
|
||||
def test_print_tui_exit_summary_includes_resume_and_token_totals(monkeypatch, capsys):
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Tests for _web_ui_build_needed — staleness check for the web UI dist.
|
||||
|
||||
Critical invariant: the Vite build outputs to hermes_cli/web_dist/
|
||||
(vite.config.ts: outDir: "../hermes_cli/web_dist"), NOT web/dist/.
|
||||
The sentinel must be checked in the correct output directory or the
|
||||
freshness check is a no-op and the OOM rebuild always runs.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.main import _web_ui_build_needed, _build_web_ui
|
||||
|
||||
|
||||
def _touch(path: Path, offset: float = 0.0) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.touch()
|
||||
if offset:
|
||||
t = time.time() + offset
|
||||
os.utime(path, (t, t))
|
||||
|
||||
|
||||
def _make_web_dir(tmp_path: Path) -> tuple[Path, Path]:
|
||||
"""Return (web_dir, dist_dir) matching real repo layout."""
|
||||
web_dir = tmp_path / "web"
|
||||
web_dir.mkdir()
|
||||
(web_dir / "package.json").touch()
|
||||
dist_dir = tmp_path / "hermes_cli" / "web_dist"
|
||||
return web_dir, dist_dir
|
||||
|
||||
|
||||
class TestWebUIBuildNeeded:
|
||||
|
||||
def test_returns_true_when_dist_missing(self, tmp_path):
|
||||
web_dir, _ = _make_web_dir(tmp_path)
|
||||
assert _web_ui_build_needed(web_dir) is True
|
||||
|
||||
def test_returns_false_when_vite_manifest_fresh(self, tmp_path):
|
||||
web_dir, dist_dir = _make_web_dir(tmp_path)
|
||||
_touch(web_dir / "src" / "App.tsx", offset=-10)
|
||||
_touch(dist_dir / ".vite" / "manifest.json")
|
||||
assert _web_ui_build_needed(web_dir) is False
|
||||
|
||||
def test_returns_true_when_source_newer_than_manifest(self, tmp_path):
|
||||
web_dir, dist_dir = _make_web_dir(tmp_path)
|
||||
_touch(dist_dir / ".vite" / "manifest.json", offset=-10)
|
||||
_touch(web_dir / "src" / "App.tsx")
|
||||
assert _web_ui_build_needed(web_dir) is True
|
||||
|
||||
def test_falls_back_to_index_html_when_manifest_missing(self, tmp_path):
|
||||
web_dir, dist_dir = _make_web_dir(tmp_path)
|
||||
_touch(web_dir / "src" / "main.ts", offset=-10)
|
||||
_touch(dist_dir / "index.html")
|
||||
assert _web_ui_build_needed(web_dir) is False
|
||||
|
||||
def test_web_dist_dir_not_web_dist_subdir(self, tmp_path):
|
||||
"""Regression: sentinel must be in hermes_cli/web_dist/, NOT web/dist/."""
|
||||
web_dir, dist_dir = _make_web_dir(tmp_path)
|
||||
_touch(web_dir / "src" / "App.tsx", offset=-10)
|
||||
# Place manifest in wrong location (web/dist/) — should NOT count as fresh
|
||||
wrong_dist = web_dir / "dist" / ".vite" / "manifest.json"
|
||||
_touch(wrong_dist)
|
||||
# Correct location is empty → still needs build
|
||||
assert _web_ui_build_needed(web_dir) is True
|
||||
|
||||
def test_returns_true_when_package_lock_newer_than_dist(self, tmp_path):
|
||||
web_dir, dist_dir = _make_web_dir(tmp_path)
|
||||
_touch(dist_dir / ".vite" / "manifest.json", offset=-10)
|
||||
_touch(web_dir / "package-lock.json")
|
||||
assert _web_ui_build_needed(web_dir) is True
|
||||
|
||||
def test_returns_true_when_vite_config_newer_than_dist(self, tmp_path):
|
||||
web_dir, dist_dir = _make_web_dir(tmp_path)
|
||||
_touch(dist_dir / ".vite" / "manifest.json", offset=-10)
|
||||
_touch(web_dir / "vite.config.ts")
|
||||
assert _web_ui_build_needed(web_dir) is True
|
||||
|
||||
def test_ignores_node_modules(self, tmp_path):
|
||||
web_dir, dist_dir = _make_web_dir(tmp_path)
|
||||
# package.json older than manifest; only node_modules file is newer
|
||||
_touch(web_dir / "package.json", offset=-20)
|
||||
_touch(dist_dir / ".vite" / "manifest.json", offset=-10)
|
||||
_touch(web_dir / "node_modules" / "react" / "index.js")
|
||||
assert _web_ui_build_needed(web_dir) is False
|
||||
|
||||
def test_ignores_dist_subdir_under_web(self, tmp_path):
|
||||
web_dir, dist_dir = _make_web_dir(tmp_path)
|
||||
# package.json older than manifest; only web/dist file is newer
|
||||
_touch(web_dir / "package.json", offset=-20)
|
||||
_touch(dist_dir / ".vite" / "manifest.json", offset=-10)
|
||||
_touch(web_dir / "dist" / "assets" / "index.js")
|
||||
assert _web_ui_build_needed(web_dir) is False
|
||||
|
||||
|
||||
class TestBuildWebUISkipsWhenFresh:
|
||||
|
||||
def test_skips_npm_when_dist_is_fresh(self, tmp_path):
|
||||
web_dir, dist_dir = _make_web_dir(tmp_path)
|
||||
_touch(dist_dir / ".vite" / "manifest.json")
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main.subprocess.run") as mock_run:
|
||||
result = _build_web_ui(web_dir)
|
||||
|
||||
assert result is True
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_runs_npm_when_dist_missing(self, tmp_path):
|
||||
web_dir, _ = _make_web_dir(tmp_path)
|
||||
|
||||
mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout=b"", stderr=b"")
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main.subprocess.run", return_value=mock_cp) as mock_run:
|
||||
result = _build_web_ui(web_dir)
|
||||
|
||||
assert result is True
|
||||
assert mock_run.call_count == 2 # npm install + npm run build
|
||||
@@ -7,6 +7,7 @@ turn counting, tags), and schema completeness.
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
@@ -18,6 +19,7 @@ from plugins.memory.hindsight import (
|
||||
REFLECT_SCHEMA,
|
||||
RETAIN_SCHEMA,
|
||||
_load_config,
|
||||
_build_embedded_profile_env,
|
||||
_normalize_retain_tags,
|
||||
_resolve_bank_id_template,
|
||||
_sanitize_bank_segment,
|
||||
@@ -34,7 +36,8 @@ def _clean_env(monkeypatch):
|
||||
"""Ensure no stale env vars leak between tests."""
|
||||
for key in (
|
||||
"HINDSIGHT_API_KEY", "HINDSIGHT_API_URL", "HINDSIGHT_BANK_ID",
|
||||
"HINDSIGHT_BUDGET", "HINDSIGHT_MODE", "HINDSIGHT_LLM_API_KEY",
|
||||
"HINDSIGHT_BUDGET", "HINDSIGHT_MODE", "HINDSIGHT_TIMEOUT",
|
||||
"HINDSIGHT_IDLE_TIMEOUT", "HINDSIGHT_LLM_API_KEY",
|
||||
"HINDSIGHT_RETAIN_TAGS", "HINDSIGHT_RETAIN_SOURCE",
|
||||
"HINDSIGHT_RETAIN_USER_PREFIX", "HINDSIGHT_RETAIN_ASSISTANT_PREFIX",
|
||||
):
|
||||
@@ -251,6 +254,51 @@ class TestConfig:
|
||||
assert cfg["banks"]["hermes"]["bankId"] == "env-bank"
|
||||
assert cfg["banks"]["hermes"]["budget"] == "high"
|
||||
|
||||
def test_embedded_profile_env_includes_idle_timeout_from_config(self):
|
||||
env = _build_embedded_profile_env({
|
||||
"llm_provider": "openai",
|
||||
"llm_model": "gpt-4o-mini",
|
||||
"idle_timeout": 0,
|
||||
})
|
||||
|
||||
assert env["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] == "0"
|
||||
|
||||
def test_embedded_profile_env_includes_idle_timeout_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("HINDSIGHT_IDLE_TIMEOUT", "42")
|
||||
|
||||
env = _build_embedded_profile_env({
|
||||
"llm_provider": "openai",
|
||||
"llm_model": "gpt-4o-mini",
|
||||
})
|
||||
|
||||
assert env["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] == "42"
|
||||
|
||||
def test_get_client_passes_idle_timeout_to_hindsight_embedded(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class FakeHindsightEmbedded:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
monkeypatch.setitem(sys.modules, "hindsight", SimpleNamespace(HindsightEmbedded=FakeHindsightEmbedded))
|
||||
monkeypatch.setattr("plugins.memory.hindsight._check_local_runtime", lambda: (True, ""))
|
||||
|
||||
p = HindsightMemoryProvider()
|
||||
p._mode = "local_embedded"
|
||||
p._config = {
|
||||
"profile": "hermes",
|
||||
"llm_provider": "openai_compatible",
|
||||
"llm_api_key": "test-key",
|
||||
"llm_model": "test-model",
|
||||
"idle_timeout": 0,
|
||||
}
|
||||
p._llm_base_url = "http://localhost:8060/v1"
|
||||
|
||||
p._get_client()
|
||||
|
||||
assert captured["idle_timeout"] == 0
|
||||
assert captured["llm_provider"] == "openai"
|
||||
|
||||
|
||||
class TestPostSetup:
|
||||
def test_local_embedded_setup_materializes_profile_env(self, tmp_path, monkeypatch):
|
||||
@@ -272,7 +320,10 @@ class TestPostSetup:
|
||||
provider.post_setup(str(hermes_home), {"memory": {}})
|
||||
|
||||
assert saved_configs[-1]["memory"]["provider"] == "hindsight"
|
||||
assert (hermes_home / ".env").read_text() == "HINDSIGHT_LLM_API_KEY=sk-local-test\nHINDSIGHT_TIMEOUT=120\n"
|
||||
env_text = (hermes_home / ".env").read_text()
|
||||
assert "HINDSIGHT_LLM_API_KEY=sk-local-test\n" in env_text
|
||||
assert "HINDSIGHT_TIMEOUT=120\n" in env_text
|
||||
assert "HINDSIGHT_IDLE_TIMEOUT=300\n" in env_text
|
||||
|
||||
profile_env = user_home / ".hindsight" / "profiles" / "hermes.env"
|
||||
assert profile_env.exists()
|
||||
@@ -281,6 +332,7 @@ class TestPostSetup:
|
||||
"HINDSIGHT_API_LLM_API_KEY=sk-local-test\n"
|
||||
"HINDSIGHT_API_LLM_MODEL=gpt-4o-mini\n"
|
||||
"HINDSIGHT_API_LOG_LEVEL=info\n"
|
||||
"HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT=300\n"
|
||||
)
|
||||
|
||||
def test_local_embedded_setup_respects_existing_profile_name(self, tmp_path, monkeypatch):
|
||||
@@ -446,6 +498,28 @@ class TestToolHandlers:
|
||||
))
|
||||
assert "error" in result
|
||||
|
||||
def test_local_embedded_recall_reconnects_after_idle_shutdown(self, provider, monkeypatch):
|
||||
first_client = _make_mock_client()
|
||||
first_client.arecall.side_effect = RuntimeError("Cannot connect to host 127.0.0.1:8888")
|
||||
second_client = _make_mock_client()
|
||||
second_client.arecall.return_value = SimpleNamespace(
|
||||
results=[SimpleNamespace(text="Recovered memory")]
|
||||
)
|
||||
clients = iter([first_client, second_client])
|
||||
|
||||
provider._mode = "local_embedded"
|
||||
provider._client = first_client
|
||||
monkeypatch.setattr(provider, "_get_client", lambda: next(clients))
|
||||
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"hindsight_recall", {"query": "test"}
|
||||
))
|
||||
|
||||
assert result["result"] == "1. Recovered memory"
|
||||
assert provider._client is second_client
|
||||
first_client.arecall.assert_called_once()
|
||||
second_client.arecall.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prefetch tests
|
||||
@@ -1102,3 +1176,22 @@ class TestSharedEventLoopLifecycle:
|
||||
|
||||
mock_client.aclose.assert_called_once()
|
||||
assert provider._client is None
|
||||
|
||||
|
||||
class TestShutdown:
|
||||
def test_local_embedded_shutdown_closes_inner_async_client_on_shared_loop(self, provider):
|
||||
inner_client = _make_mock_client()
|
||||
embedded = MagicMock()
|
||||
embedded._client = inner_client
|
||||
embedded.close = MagicMock()
|
||||
|
||||
provider._mode = "local_embedded"
|
||||
provider._client = embedded
|
||||
|
||||
provider.shutdown()
|
||||
|
||||
inner_client.aclose.assert_awaited_once()
|
||||
embedded.close.assert_called_once()
|
||||
assert embedded._client is None
|
||||
assert provider._client is None
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user