Compare commits
222 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 | |||
| e3901d5b25 | |||
| 06f81752ed | |||
| 9ef1ae138a | |||
| c5196f1fc2 | |||
| 63bf7a29b6 | |||
| 15937a6b46 | |||
| 454d883e69 | |||
| 70f56e7605 | |||
| 7fa70b6c87 | |||
| 9a70260490 | |||
| ffd2621039 | |||
| 1e37ddc929 | |||
| 83c1c201f6 | |||
| 4bda9dcade | |||
| 67dcace412 | |||
| 35c57cc46b | |||
| e8441c4c0f | |||
| 2511207cb0 | |||
| 0f3a6f0fb3 | |||
| a562420383 | |||
| 855366909f | |||
| d09ab8ff13 | |||
| 438db0c7b0 | |||
| 2ccdadcca6 | |||
| 76042f5867 | |||
| 192e7eb21f | |||
| 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}"
|
||||
|
||||
+16
-5
@@ -145,10 +145,11 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"claude": 200000,
|
||||
# OpenAI — GPT-5 family (most have 400k; specific overrides first)
|
||||
# Source: https://developers.openai.com/api/docs/models
|
||||
# GPT-5.5 (launched Apr 23 2026). 400k is the fallback for providers we
|
||||
# can't probe live. ChatGPT Codex OAuth actually caps lower (272k as of
|
||||
# Apr 2026) and is resolved via _resolve_codex_oauth_context_length().
|
||||
"gpt-5.5": 400000,
|
||||
# GPT-5.5 (launched Apr 23 2026) is 1.05M on the direct OpenAI API and
|
||||
# ChatGPT Codex OAuth caps it at 272K; both paths resolve via their own
|
||||
# provider-aware branches (_resolve_codex_oauth_context_length + models.dev).
|
||||
# This hardcoded value is only reached when every probe misses.
|
||||
"gpt-5.5": 1050000,
|
||||
"gpt-5.4-nano": 400000, # 400k (not 1.05M like full 5.4)
|
||||
"gpt-5.4-mini": 400000, # 400k (not 1.05M like full 5.4)
|
||||
"gpt-5.4": 1050000, # GPT-5.4, GPT-5.4 Pro (1.05M context)
|
||||
@@ -164,7 +165,17 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"gemma-4-31b": 256000,
|
||||
"gemma-3": 131072,
|
||||
"gemma": 8192, # fallback for older gemma models
|
||||
# DeepSeek
|
||||
# DeepSeek — V4 family ships with a 1M context window. The legacy
|
||||
# aliases ``deepseek-chat`` / ``deepseek-reasoner`` are server-side
|
||||
# mapped to the non-thinking / thinking modes of ``deepseek-v4-flash``
|
||||
# and inherit the same 1M window. The ``deepseek`` substring entry
|
||||
# below remains as a 128K fallback for older / unknown DeepSeek model
|
||||
# ids (e.g. via custom endpoints).
|
||||
# https://api-docs.deepseek.com/zh-cn/quick_start/pricing
|
||||
"deepseek-v4-pro": 1_000_000,
|
||||
"deepseek-v4-flash": 1_000_000,
|
||||
"deepseek-chat": 1_000_000,
|
||||
"deepseek-reasoner": 1_000_000,
|
||||
"deepseek": 128000,
|
||||
# Meta
|
||||
"llama": 131072,
|
||||
|
||||
@@ -180,3 +180,145 @@ def format_remaining(seconds: float) -> str:
|
||||
h, remainder = divmod(s, 3600)
|
||||
m = remainder // 60
|
||||
return f"{h}h {m}m" if m else f"{h}h"
|
||||
|
||||
|
||||
# Buckets with reset windows shorter than this are treated as transient
|
||||
# (upstream jitter, secondary throttling) rather than a genuine quota
|
||||
# exhaustion worth a cross-session breaker trip.
|
||||
_MIN_RESET_FOR_BREAKER_SECONDS = 60.0
|
||||
|
||||
|
||||
def is_genuine_nous_rate_limit(
|
||||
*,
|
||||
headers: Optional[Mapping[str, str]] = None,
|
||||
last_known_state: Optional[Any] = None,
|
||||
) -> bool:
|
||||
"""Decide whether a 429 from Nous Portal is a real account rate limit.
|
||||
|
||||
Nous Portal multiplexes multiple upstream providers (DeepSeek, Kimi,
|
||||
MiMo, Hermes, ...) behind one endpoint. A 429 can mean either:
|
||||
|
||||
(a) The caller's own RPM / RPH / TPM / TPH bucket on Nous is
|
||||
exhausted — a genuine rate limit that will last until the
|
||||
bucket resets.
|
||||
(b) The upstream provider is out of capacity for a specific model
|
||||
— transient, clears in seconds, and has nothing to do with
|
||||
the caller's quota on Nous.
|
||||
|
||||
Tripping the cross-session breaker on (b) blocks ALL Nous requests
|
||||
(and all models, since Nous is one provider key) for minutes even
|
||||
though the caller's account is healthy and a different model would
|
||||
have worked. That's the bug users hit when DeepSeek V4 Pro 429s
|
||||
trigger a breaker that then blocks Kimi 2.6 and MiMo V2.5 Pro.
|
||||
|
||||
We tell the two apart by looking at:
|
||||
|
||||
1. The 429 response's own ``x-ratelimit-*`` headers. Nous emits
|
||||
the full suite on every response including 429s. An exhausted
|
||||
bucket (``remaining == 0`` with a reset window >= 60s) is
|
||||
proof of (a).
|
||||
2. The last-known-good rate-limit state captured by
|
||||
``_capture_rate_limits()`` on the previous successful
|
||||
response. If any bucket there was already near-exhausted with
|
||||
a substantial reset window, the current 429 is almost
|
||||
certainly (a) continuing from that condition.
|
||||
|
||||
If neither signal fires, we treat the 429 as (b): fail the single
|
||||
request, let the retry loop or model-switch proceed, and do NOT
|
||||
write the cross-session breaker file.
|
||||
|
||||
Returns True when the evidence points at (a).
|
||||
"""
|
||||
# Signal 1: current 429 response headers.
|
||||
state = _parse_buckets_from_headers(headers)
|
||||
if _has_exhausted_bucket(state):
|
||||
return True
|
||||
|
||||
# Signal 2: last-known-good state from a recent successful response.
|
||||
# Accepts either a RateLimitState (dataclass from rate_limit_tracker)
|
||||
# or a dict of bucket snapshots.
|
||||
if last_known_state is not None and _has_exhausted_bucket_in_object(last_known_state):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _parse_buckets_from_headers(
|
||||
headers: Optional[Mapping[str, str]],
|
||||
) -> dict[str, tuple[Optional[int], Optional[float]]]:
|
||||
"""Extract (remaining, reset_seconds) per bucket from x-ratelimit-* headers.
|
||||
|
||||
Returns empty dict when no rate-limit headers are present.
|
||||
"""
|
||||
if not headers:
|
||||
return {}
|
||||
|
||||
lowered = {k.lower(): v for k, v in headers.items()}
|
||||
if not any(k.startswith("x-ratelimit-") for k in lowered):
|
||||
return {}
|
||||
|
||||
def _maybe_int(raw: Optional[str]) -> Optional[int]:
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _maybe_float(raw: Optional[str]) -> Optional[float]:
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
result: dict[str, tuple[Optional[int], Optional[float]]] = {}
|
||||
for tag in ("requests", "requests-1h", "tokens", "tokens-1h"):
|
||||
remaining = _maybe_int(lowered.get(f"x-ratelimit-remaining-{tag}"))
|
||||
reset = _maybe_float(lowered.get(f"x-ratelimit-reset-{tag}"))
|
||||
if remaining is not None or reset is not None:
|
||||
result[tag] = (remaining, reset)
|
||||
return result
|
||||
|
||||
|
||||
def _has_exhausted_bucket(
|
||||
buckets: Mapping[str, tuple[Optional[int], Optional[float]]],
|
||||
) -> bool:
|
||||
"""Return True when any bucket has remaining == 0 AND a meaningful reset window."""
|
||||
for remaining, reset in buckets.values():
|
||||
if remaining is None or remaining > 0:
|
||||
continue
|
||||
if reset is None:
|
||||
continue
|
||||
if reset >= _MIN_RESET_FOR_BREAKER_SECONDS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_exhausted_bucket_in_object(state: Any) -> bool:
|
||||
"""Check a RateLimitState-like object for an exhausted bucket.
|
||||
|
||||
Accepts the dataclass from ``agent.rate_limit_tracker`` (buckets
|
||||
exposed as attributes ``requests_min``, ``requests_hour``,
|
||||
``tokens_min``, ``tokens_hour``) and falls back gracefully for any
|
||||
object missing those attributes.
|
||||
"""
|
||||
for attr in ("requests_min", "requests_hour", "tokens_min", "tokens_hour"):
|
||||
bucket = getattr(state, attr, None)
|
||||
if bucket is None:
|
||||
continue
|
||||
limit = getattr(bucket, "limit", 0) or 0
|
||||
remaining = getattr(bucket, "remaining", 0) or 0
|
||||
# Prefer the adjusted "remaining_seconds_now" property when present;
|
||||
# fall back to raw reset_seconds.
|
||||
reset = getattr(bucket, "remaining_seconds_now", None)
|
||||
if reset is None:
|
||||
reset = getattr(bucket, "reset_seconds", 0.0) or 0.0
|
||||
if limit <= 0:
|
||||
continue
|
||||
if remaining > 0:
|
||||
continue
|
||||
if reset >= _MIN_RESET_FOR_BREAKER_SECONDS:
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Contextual first-touch onboarding hints.
|
||||
|
||||
Instead of blocking first-run questionnaires, show a one-time hint the *first*
|
||||
time a user hits a behavior fork — message-while-running, first long-running
|
||||
tool, etc. Each hint is shown once per install (tracked in ``config.yaml`` under
|
||||
``onboarding.seen.<flag>``) and then never again.
|
||||
|
||||
Keep this module tiny and dependency-free so both the CLI and gateway can import
|
||||
it without pulling in heavy modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Flag names (stable — used as config.yaml keys under onboarding.seen)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
BUSY_INPUT_FLAG = "busy_input_prompt"
|
||||
TOOL_PROGRESS_FLAG = "tool_progress_prompt"
|
||||
OPENCLAW_RESIDUE_FLAG = "openclaw_residue_cleanup"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Hint content
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def busy_input_hint_gateway(mode: str) -> str:
|
||||
"""Hint shown the first time a user messages while the agent is busy.
|
||||
|
||||
``mode`` is the effective busy_input_mode that was just applied, so the
|
||||
message matches reality ("I just interrupted…" vs "I just queued…").
|
||||
"""
|
||||
if mode == "queue":
|
||||
return (
|
||||
"💡 First-time tip — I queued your message instead of interrupting. "
|
||||
"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, "
|
||||
"`/busy steer` to inject them mid-run without interrupting, or "
|
||||
"`/busy status` to check. This notice won't appear again."
|
||||
)
|
||||
|
||||
|
||||
def busy_input_hint_cli(mode: str) -> str:
|
||||
"""CLI version of the busy-input hint (plain text, no markdown)."""
|
||||
if mode == "queue":
|
||||
return (
|
||||
"(tip) Your message was queued for the next turn. "
|
||||
"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, "
|
||||
"or /busy steer to inject mid-run. This tip only shows once."
|
||||
)
|
||||
|
||||
|
||||
def tool_progress_hint_gateway() -> str:
|
||||
return (
|
||||
"💡 First-time tip — that tool took a while and I'm streaming every step. "
|
||||
"If the progress messages feel noisy, send `/verbose` to cycle modes "
|
||||
"(all → new → off). This notice won't appear again."
|
||||
)
|
||||
|
||||
|
||||
def tool_progress_hint_cli() -> str:
|
||||
return (
|
||||
"(tip) That tool ran for a while. Use /verbose to cycle tool-progress "
|
||||
"display modes (all -> new -> off -> verbose). This tip only shows once."
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _get_seen_dict(config: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
onboarding = config.get("onboarding") if isinstance(config, Mapping) else None
|
||||
if not isinstance(onboarding, Mapping):
|
||||
return {}
|
||||
seen = onboarding.get("seen")
|
||||
return seen if isinstance(seen, Mapping) else {}
|
||||
|
||||
|
||||
def is_seen(config: Mapping[str, Any], flag: str) -> bool:
|
||||
"""Return True if the user has already been shown this first-touch hint."""
|
||||
return bool(_get_seen_dict(config).get(flag))
|
||||
|
||||
|
||||
def mark_seen(config_path: Path, flag: str) -> bool:
|
||||
"""Persist ``onboarding.seen.<flag> = True`` to ``config_path``.
|
||||
|
||||
Uses the atomic YAML writer so a concurrent process can't observe a
|
||||
partially-written file. Returns True on success, False on any error
|
||||
(including the config file being absent — onboarding is best-effort).
|
||||
"""
|
||||
try:
|
||||
import yaml
|
||||
from utils import atomic_yaml_write
|
||||
except Exception as e: # pragma: no cover — dependency issue
|
||||
logger.debug("onboarding: failed to import yaml/utils: %s", e)
|
||||
return False
|
||||
|
||||
try:
|
||||
cfg: dict = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
if not isinstance(cfg.get("onboarding"), dict):
|
||||
cfg["onboarding"] = {}
|
||||
seen = cfg["onboarding"].get("seen")
|
||||
if not isinstance(seen, dict):
|
||||
seen = {}
|
||||
cfg["onboarding"]["seen"] = seen
|
||||
if seen.get(flag) is True:
|
||||
return True # already marked — nothing to do
|
||||
seen[flag] = True
|
||||
atomic_yaml_write(config_path, cfg)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("onboarding: failed to mark flag %s: %s", flag, e)
|
||||
return False
|
||||
|
||||
|
||||
__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.]"
|
||||
)
|
||||
|
||||
+28
-8
@@ -606,6 +606,7 @@ platform_toolsets:
|
||||
signal: [hermes-signal]
|
||||
homeassistant: [hermes-homeassistant]
|
||||
qqbot: [hermes-qqbot]
|
||||
yuanbao: [hermes-yuanbao]
|
||||
|
||||
# =============================================================================
|
||||
# Gateway Platform Settings
|
||||
@@ -824,7 +825,9 @@ delegation:
|
||||
# Display
|
||||
# =============================================================================
|
||||
display:
|
||||
# Use compact banner mode
|
||||
# Use compact banner mode (hides the ASCII-art banner, shows a single line).
|
||||
# true: Compact single-line banner
|
||||
# false: Full ASCII banner with tool/skill summary (default)
|
||||
compact: false
|
||||
|
||||
# Tool progress display level (CLI and gateway)
|
||||
@@ -838,12 +841,19 @@ display:
|
||||
# Gateway-only natural mid-turn assistant updates.
|
||||
# When true, completed assistant status messages are sent as separate chat
|
||||
# messages. This is independent of tool_progress and gateway streaming.
|
||||
# true: Send mid-turn assistant updates as separate messages (default)
|
||||
# false: Only send the final response
|
||||
interim_assistant_messages: true
|
||||
|
||||
# What Enter does when Hermes is already busy in the CLI.
|
||||
# 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
|
||||
# Ctrl+C always interrupts regardless of this setting.
|
||||
# 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 <interrupt|queue|steer>.
|
||||
busy_input_mode: interrupt
|
||||
|
||||
# Background process notifications (gateway/messaging only).
|
||||
@@ -859,17 +869,22 @@ display:
|
||||
# Play terminal bell when agent finishes a response.
|
||||
# Useful for long-running tasks — your terminal will ding when the agent is done.
|
||||
# Works over SSH. Most terminals can be configured to flash the taskbar or play a sound.
|
||||
# true: Ring the terminal bell on each response
|
||||
# false: Silent (default)
|
||||
bell_on_complete: false
|
||||
|
||||
# Show model reasoning/thinking before each response.
|
||||
# When enabled, a dim box shows the model's thought process above the response.
|
||||
# Toggle at runtime with /reasoning show or /reasoning hide.
|
||||
# true: Show the reasoning box
|
||||
# false: Hide reasoning (default)
|
||||
show_reasoning: false
|
||||
|
||||
# Stream tokens to the terminal as they arrive instead of waiting for the
|
||||
# full response. The response box opens on first token and text appears
|
||||
# line-by-line. Tool calls are still captured silently.
|
||||
# Stream tokens to the terminal in real-time. Disable to wait for full responses.
|
||||
# true: Stream tokens as they arrive (default)
|
||||
# false: Wait for the full response before rendering
|
||||
streaming: true
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
@@ -879,10 +894,15 @@ display:
|
||||
# response box label, and branding text. Change at runtime with /skin <name>.
|
||||
#
|
||||
# Built-in skins:
|
||||
# default — Classic Hermes gold/kawaii
|
||||
# ares — Crimson/bronze war-god theme with spinner wings
|
||||
# mono — Clean grayscale monochrome
|
||||
# slate — Cool blue developer-focused
|
||||
# default — Classic Hermes gold/kawaii
|
||||
# ares — Crimson/bronze war-god theme with spinner wings
|
||||
# mono — Clean grayscale monochrome
|
||||
# slate — Cool blue developer-focused
|
||||
# daylight — Bright light-mode theme
|
||||
# warm-lightmode — Warm paper-tone light-mode theme
|
||||
# poseidon — Sea-green/teal Olympian theme
|
||||
# sisyphus — Earthy stone-and-moss theme
|
||||
# charizard — Fiery orange dragon theme
|
||||
#
|
||||
# Custom skins: drop a YAML file in ~/.hermes/skins/<name>.yaml
|
||||
# Schema (all fields optional, missing values inherit from default):
|
||||
|
||||
@@ -417,6 +417,11 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
"base_url": "", # Direct OpenAI-compatible endpoint for subagents
|
||||
"api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY)
|
||||
},
|
||||
"onboarding": {
|
||||
# First-touch hint flags (see agent/onboarding.py). Each hint is
|
||||
# shown once per install then latched here.
|
||||
"seen": {},
|
||||
},
|
||||
}
|
||||
|
||||
# Track whether the config file explicitly set terminal config.
|
||||
@@ -969,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
|
||||
@@ -976,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.
|
||||
|
||||
@@ -1373,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.
|
||||
@@ -1383,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}"
|
||||
@@ -1404,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}]"
|
||||
@@ -1843,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")
|
||||
|
||||
@@ -2040,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
|
||||
|
||||
@@ -4910,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)
|
||||
@@ -4931,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}")
|
||||
|
||||
@@ -5153,27 +5216,29 @@ class HermesCLI:
|
||||
_cprint(f" ✓ Model switched: {result.new_model}")
|
||||
_cprint(f" Provider: {provider_label}")
|
||||
|
||||
# Context: always resolve via the provider-aware chain so Codex OAuth,
|
||||
# Copilot, and Nous-enforced caps win over the raw models.dev entry
|
||||
# (e.g. gpt-5.5 is 1.05M on openai but 272K on Codex OAuth).
|
||||
mi = result.model_info
|
||||
try:
|
||||
from hermes_cli.model_switch import resolve_display_context_length
|
||||
ctx = resolve_display_context_length(
|
||||
result.new_model,
|
||||
result.target_provider,
|
||||
base_url=result.base_url or self.base_url or "",
|
||||
api_key=result.api_key or self.api_key or "",
|
||||
model_info=mi,
|
||||
)
|
||||
if ctx:
|
||||
_cprint(f" Context: {ctx:,} tokens")
|
||||
except Exception:
|
||||
pass
|
||||
if mi:
|
||||
if mi.context_window:
|
||||
_cprint(f" Context: {mi.context_window:,} tokens")
|
||||
if mi.max_output:
|
||||
_cprint(f" Max output: {mi.max_output:,} tokens")
|
||||
if mi.has_cost_data():
|
||||
_cprint(f" Cost: {mi.format_cost()}")
|
||||
_cprint(f" Capabilities: {mi.format_capabilities()}")
|
||||
else:
|
||||
try:
|
||||
from agent.model_metadata import get_model_context_length
|
||||
ctx = get_model_context_length(
|
||||
result.new_model,
|
||||
base_url=result.base_url or self.base_url,
|
||||
api_key=result.api_key or self.api_key,
|
||||
provider=result.target_provider,
|
||||
)
|
||||
_cprint(f" Context: {ctx:,} tokens")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cache_enabled = (
|
||||
(base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower())
|
||||
@@ -6122,8 +6187,6 @@ class HermesCLI:
|
||||
self._handle_agents_command()
|
||||
elif canonical == "background":
|
||||
self._handle_background_command(cmd_original)
|
||||
elif canonical == "btw":
|
||||
self._handle_btw_command(cmd_original)
|
||||
elif canonical == "queue":
|
||||
# Extract prompt after "/queue " or "/q "
|
||||
parts = cmd_original.split(None, 1)
|
||||
@@ -6302,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"],
|
||||
@@ -6399,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:
|
||||
@@ -6410,122 +6485,6 @@ class HermesCLI:
|
||||
self._background_tasks[task_id] = thread
|
||||
thread.start()
|
||||
|
||||
def _handle_btw_command(self, cmd: str):
|
||||
"""Handle /btw <question> — ephemeral side question using session context.
|
||||
|
||||
Snapshots the current conversation history, spawns a no-tools agent in
|
||||
a background thread, and prints the answer without persisting anything
|
||||
to the main session.
|
||||
"""
|
||||
parts = cmd.strip().split(maxsplit=1)
|
||||
if len(parts) < 2 or not parts[1].strip():
|
||||
_cprint(" Usage: /btw <question>")
|
||||
_cprint(" Example: /btw what module owns session title sanitization?")
|
||||
_cprint(" Answers using session context. No tools, not persisted.")
|
||||
return
|
||||
|
||||
question = parts[1].strip()
|
||||
task_id = f"btw_{datetime.now().strftime('%H%M%S')}_{uuid.uuid4().hex[:6]}"
|
||||
|
||||
if not self._ensure_runtime_credentials():
|
||||
_cprint(" (>_<) Cannot start /btw: no valid credentials.")
|
||||
return
|
||||
|
||||
turn_route = self._resolve_turn_agent_config(question)
|
||||
history_snapshot = list(self.conversation_history)
|
||||
|
||||
preview = question[:60] + ("..." if len(question) > 60 else "")
|
||||
_cprint(f' 💬 /btw: "{preview}"')
|
||||
|
||||
def run_btw():
|
||||
try:
|
||||
btw_agent = AIAgent(
|
||||
model=turn_route["model"],
|
||||
api_key=turn_route["runtime"].get("api_key"),
|
||||
base_url=turn_route["runtime"].get("base_url"),
|
||||
provider=turn_route["runtime"].get("provider"),
|
||||
api_mode=turn_route["runtime"].get("api_mode"),
|
||||
acp_command=turn_route["runtime"].get("command"),
|
||||
acp_args=turn_route["runtime"].get("args"),
|
||||
max_iterations=8,
|
||||
enabled_toolsets=[],
|
||||
quiet_mode=True,
|
||||
verbose_logging=False,
|
||||
session_id=task_id,
|
||||
platform="cli",
|
||||
reasoning_config=self.reasoning_config,
|
||||
service_tier=self.service_tier,
|
||||
request_overrides=turn_route.get("request_overrides"),
|
||||
providers_allowed=self._providers_only,
|
||||
providers_ignored=self._providers_ignore,
|
||||
providers_order=self._providers_order,
|
||||
provider_sort=self._provider_sort,
|
||||
provider_require_parameters=self._provider_require_params,
|
||||
provider_data_collection=self._provider_data_collection,
|
||||
fallback_model=self._fallback_model,
|
||||
session_db=None,
|
||||
skip_memory=True,
|
||||
skip_context_files=True,
|
||||
persist_session=False,
|
||||
)
|
||||
|
||||
btw_prompt = (
|
||||
"[Ephemeral /btw side question. Answer using the conversation "
|
||||
"context. No tools available. Be direct and concise.]\n\n"
|
||||
+ question
|
||||
)
|
||||
result = btw_agent.run_conversation(
|
||||
user_message=btw_prompt,
|
||||
conversation_history=history_snapshot,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
response = (result.get("final_response") or "") if result else ""
|
||||
if not response and result and result.get("error"):
|
||||
response = f"Error: {result['error']}"
|
||||
|
||||
# TUI refresh before printing
|
||||
if self._app:
|
||||
self._app.invalidate()
|
||||
time.sleep(0.05)
|
||||
print()
|
||||
|
||||
if response:
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
_skin = get_active_skin()
|
||||
_resp_color = _skin.get_color("response_border", "#4F6D4A")
|
||||
except Exception:
|
||||
_resp_color = "#4F6D4A"
|
||||
|
||||
ChatConsole().print(Panel(
|
||||
_render_final_assistant_content(response, mode=self.final_response_markdown),
|
||||
title=f"[{_resp_color} bold]⚕ /btw[/]",
|
||||
title_align="left",
|
||||
border_style=_resp_color,
|
||||
box=rich_box.HORIZONTALS,
|
||||
padding=(1, 4),
|
||||
))
|
||||
else:
|
||||
_cprint(" 💬 /btw: (no response)")
|
||||
|
||||
if self.bell_on_complete:
|
||||
sys.stdout.write("\a")
|
||||
sys.stdout.flush()
|
||||
|
||||
except Exception as e:
|
||||
if self._app:
|
||||
self._app.invalidate()
|
||||
time.sleep(0.05)
|
||||
print()
|
||||
_cprint(f" ❌ /btw failed: {e}")
|
||||
finally:
|
||||
if self._app:
|
||||
self._invalidate(min_interval=0)
|
||||
|
||||
thread = threading.Thread(target=run_btw, daemon=True, name=f"btw-{task_id}")
|
||||
thread.start()
|
||||
|
||||
@staticmethod
|
||||
def _try_launch_chrome_debug(port: int, system: str) -> bool:
|
||||
"""Try to launch Chrome/Chromium with remote debugging enabled.
|
||||
@@ -6909,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:
|
||||
@@ -7328,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
|
||||
@@ -7410,6 +7381,31 @@ class HermesCLI:
|
||||
_cprint(f" {line}")
|
||||
except Exception:
|
||||
pass
|
||||
# First-touch onboarding: on the first tool in this process
|
||||
# that takes longer than the threshold while we're in the
|
||||
# noisiest progress mode, print a one-time hint about
|
||||
# /verbose. Latched on self so it fires at most once per
|
||||
# process; persisted to config.yaml so it never fires again
|
||||
# across processes either.
|
||||
try:
|
||||
if (
|
||||
not getattr(self, "_long_tool_hint_fired", False)
|
||||
and self.tool_progress_mode == "all"
|
||||
and duration >= 30.0
|
||||
):
|
||||
from agent.onboarding import (
|
||||
TOOL_PROGRESS_FLAG,
|
||||
is_seen,
|
||||
mark_seen,
|
||||
tool_progress_hint_cli,
|
||||
)
|
||||
if not is_seen(CLI_CONFIG, TOOL_PROGRESS_FLAG):
|
||||
self._long_tool_hint_fired = True
|
||||
_cprint(f" {_DIM}{tool_progress_hint_cli()}{_RST}")
|
||||
mark_seen(_hermes_home / "config.yaml", TOOL_PROGRESS_FLAG)
|
||||
CLI_CONFIG.setdefault("onboarding", {}).setdefault("seen", {})[TOOL_PROGRESS_FLAG] = True
|
||||
except Exception:
|
||||
pass
|
||||
self._invalidate()
|
||||
return
|
||||
if event_type != "tool.started":
|
||||
@@ -9077,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
|
||||
@@ -9278,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:
|
||||
@@ -9293,6 +9335,24 @@ class HermesCLI:
|
||||
f"agent_running={self._agent_running}\n")
|
||||
except Exception:
|
||||
pass
|
||||
# First-touch onboarding: on the very first busy-while-running
|
||||
# event for this install, print a one-line tip explaining the
|
||||
# /busy knob. Flag persists to config.yaml and never fires
|
||||
# again. Guarded for exceptions so onboarding can't break
|
||||
# the input loop.
|
||||
try:
|
||||
from agent.onboarding import (
|
||||
BUSY_INPUT_FLAG,
|
||||
busy_input_hint_cli,
|
||||
is_seen,
|
||||
mark_seen,
|
||||
)
|
||||
if not is_seen(CLI_CONFIG, BUSY_INPUT_FLAG):
|
||||
_cprint(f" {_DIM}{busy_input_hint_cli(self.busy_input_mode)}{_RST}")
|
||||
mark_seen(_hermes_home / "config.yaml", BUSY_INPUT_FLAG)
|
||||
CLI_CONFIG.setdefault("onboarding", {}).setdefault("seen", {})[BUSY_INPUT_FLAG] = True
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
self._pending_input.put(payload)
|
||||
event.app.current_buffer.reset(append_to_history=True)
|
||||
@@ -9909,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:
|
||||
|
||||
@@ -41,6 +41,15 @@ if [ "$(id -u)" = "0" ]; then
|
||||
echo "Warning: chown failed (rootless container?) — continuing anyway"
|
||||
fi
|
||||
|
||||
# Ensure config.yaml is readable by the hermes runtime user even if it was
|
||||
# edited on the host after initial ownership setup. Must run here (as root)
|
||||
# rather than after the gosu drop, otherwise a non-root caller like
|
||||
# `docker run -u $(id -u):$(id -g)` hits "Operation not permitted" (#15865).
|
||||
if [ -f "$HERMES_HOME/config.yaml" ]; then
|
||||
chown hermes:hermes "$HERMES_HOME/config.yaml" 2>/dev/null || true
|
||||
chmod 640 "$HERMES_HOME/config.yaml" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "Dropping root privileges"
|
||||
exec gosu hermes "$0" "$@"
|
||||
fi
|
||||
@@ -67,13 +76,6 @@ if [ ! -f "$HERMES_HOME/config.yaml" ]; then
|
||||
cp "$INSTALL_DIR/cli-config.yaml.example" "$HERMES_HOME/config.yaml"
|
||||
fi
|
||||
|
||||
# Ensure the main config file remains accessible to the hermes runtime user
|
||||
# even if it was edited on the host after initial ownership setup.
|
||||
if [ -f "$HERMES_HOME/config.yaml" ]; then
|
||||
chown hermes:hermes "$HERMES_HOME/config.yaml"
|
||||
chmod 640 "$HERMES_HOME/config.yaml"
|
||||
fi
|
||||
|
||||
# SOUL.md
|
||||
if [ ! -f "$HERMES_HOME/SOUL.md" ]; then
|
||||
cp "$INSTALL_DIR/docker/SOUL.md" "$HERMES_HOME/SOUL.md"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
+152
-5
@@ -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.
|
||||
@@ -1025,7 +1121,20 @@ class BasePlatformAdapter(ABC):
|
||||
self._post_delivery_callbacks: Dict[str, Any] = {}
|
||||
self._expected_cancelled_tasks: set[asyncio.Task] = set()
|
||||
self._busy_session_handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]] = None
|
||||
# Chats where auto-TTS on voice input is disabled (set by /voice off)
|
||||
# Auto-TTS on voice input: ``_auto_tts_default`` is the global default
|
||||
# (``voice.auto_tts`` in config.yaml, pushed by GatewayRunner on connect).
|
||||
# Per-chat overrides live in two sets populated from ``_voice_mode``:
|
||||
# - ``_auto_tts_enabled_chats``: chat explicitly opted in via ``/voice on``
|
||||
# or ``/voice tts`` (mode is ``voice_only`` or ``all``). Fires even when
|
||||
# the global default is False.
|
||||
# - ``_auto_tts_disabled_chats``: chat explicitly opted out via
|
||||
# ``/voice off`` (mode is ``off``). Suppresses auto-TTS even when the
|
||||
# global default is True.
|
||||
# The gate in _process_message() is:
|
||||
# fire if chat in _auto_tts_enabled_chats
|
||||
# OR (_auto_tts_default and chat not in _auto_tts_disabled_chats)
|
||||
self._auto_tts_default: bool = False
|
||||
self._auto_tts_enabled_chats: set = set()
|
||||
self._auto_tts_disabled_chats: set = set()
|
||||
# Chats where typing indicator is paused (e.g. during approval waits).
|
||||
# _keep_typing skips send_typing when the chat_id is in this set.
|
||||
@@ -1047,6 +1156,21 @@ class BasePlatformAdapter(ABC):
|
||||
def fatal_error_retryable(self) -> bool:
|
||||
return self._fatal_error_retryable
|
||||
|
||||
def _should_auto_tts_for_chat(self, chat_id: str) -> bool:
|
||||
"""Whether auto-TTS on voice input should fire for ``chat_id``.
|
||||
|
||||
Decision layers (Issue #16007):
|
||||
1. Explicit ``/voice on`` or ``/voice tts`` → always fire (even if
|
||||
``voice.auto_tts`` is False).
|
||||
2. Explicit ``/voice off`` → never fire.
|
||||
3. Fall back to the global ``voice.auto_tts`` config default.
|
||||
"""
|
||||
if chat_id in self._auto_tts_enabled_chats:
|
||||
return True
|
||||
if chat_id in self._auto_tts_disabled_chats:
|
||||
return False
|
||||
return bool(self._auto_tts_default)
|
||||
|
||||
def set_fatal_error_handler(self, handler: Callable[["BasePlatformAdapter"], Awaitable[None] | None]) -> None:
|
||||
self._fatal_error_handler = handler
|
||||
|
||||
@@ -1230,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.
|
||||
@@ -2214,12 +2359,14 @@ class BasePlatformAdapter(ABC):
|
||||
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
|
||||
|
||||
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
|
||||
# Skipped when the chat has voice mode disabled (/voice off)
|
||||
# Gated via ``_should_auto_tts_for_chat``: fires when the chat has
|
||||
# an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is
|
||||
# True globally and no ``/voice off`` has been issued.
|
||||
_tts_path = None
|
||||
if (event.message_type == MessageType.VOICE
|
||||
if (self._should_auto_tts_for_chat(event.source.chat_id)
|
||||
and event.message_type == MessageType.VOICE
|
||||
and text_content
|
||||
and not media_files
|
||||
and event.source.chat_id not in self._auto_tts_disabled_chats):
|
||||
and not media_files):
|
||||
try:
|
||||
from tools.tts_tool import text_to_speech_tool, check_tts_requirements
|
||||
if check_tts_requirements():
|
||||
|
||||
@@ -2315,11 +2315,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
async def slash_background(interaction: discord.Interaction, prompt: str):
|
||||
await self._run_simple_slash(interaction, f"/background {prompt}", "Background task started~")
|
||||
|
||||
@tree.command(name="btw", description="Ephemeral side question using session context")
|
||||
@discord.app_commands.describe(question="Your side question (no tools, not persisted)")
|
||||
async def slash_btw(interaction: discord.Interaction, question: str):
|
||||
await self._run_simple_slash(interaction, f"/btw {question}")
|
||||
|
||||
# ── Auto-register any gateway-available commands not yet on the tree ──
|
||||
# This ensures new commands added to COMMAND_REGISTRY in
|
||||
# hermes_cli/commands.py automatically appear as Discord slash
|
||||
@@ -2684,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)
|
||||
+509
-221
File diff suppressed because it is too large
Load Diff
+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)
|
||||
|
||||
+19
-3
@@ -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 "", ""
|
||||
|
||||
|
||||
@@ -4244,10 +4260,10 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
||||
)
|
||||
|
||||
from hermes_cli.models import (
|
||||
_PROVIDER_MODELS, get_pricing_for_provider,
|
||||
get_curated_nous_model_ids, get_pricing_for_provider,
|
||||
check_nous_free_tier, partition_nous_models_by_tier,
|
||||
)
|
||||
model_ids = _PROVIDER_MODELS.get("nous", [])
|
||||
model_ids = get_curated_nous_model_ids()
|
||||
|
||||
print()
|
||||
unavailable_models: list = []
|
||||
|
||||
+111
-5
@@ -84,9 +84,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("deny", "Deny a pending dangerous command", "Session",
|
||||
gateway_only=True),
|
||||
CommandDef("background", "Run a prompt in the background", "Session",
|
||||
aliases=("bg",), args_hint="<prompt>"),
|
||||
CommandDef("btw", "Ephemeral side question using session context (no tools, not persisted)", "Session",
|
||||
args_hint="<question>"),
|
||||
aliases=("bg", "btw"), args_hint="<prompt>"),
|
||||
CommandDef("agents", "Show active agents and running tasks", "Session",
|
||||
aliases=("tasks",)),
|
||||
CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session",
|
||||
@@ -128,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",
|
||||
@@ -808,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.
|
||||
|
||||
|
||||
+81
-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,
|
||||
@@ -959,6 +973,27 @@ DEFAULT_CONFIG = {
|
||||
"backup_count": 3, # Number of rotated backup files to keep
|
||||
},
|
||||
|
||||
# Remotely-hosted model catalog manifest. When enabled, the CLI fetches
|
||||
# curated model lists for OpenRouter and Nous Portal from this URL,
|
||||
# falling back to the in-repo snapshot on network failure. Lets us
|
||||
# update model picker lists without shipping a hermes-agent release.
|
||||
# The default URL is served by the docs site GitHub Pages deploy.
|
||||
"model_catalog": {
|
||||
"enabled": True,
|
||||
"url": "https://hermes-agent.nousresearch.com/docs/api/model-catalog.json",
|
||||
# Disk cache TTL in hours. Beyond this, the CLI refetches on the
|
||||
# next /model or `hermes model` invocation; network failures
|
||||
# silently fall back to the stale cache.
|
||||
"ttl_hours": 24,
|
||||
# Optional per-provider override URLs for third parties that want
|
||||
# to self-host their own curation list using the same schema.
|
||||
# Example:
|
||||
# providers:
|
||||
# openrouter:
|
||||
# url: https://example.com/my-curation.json
|
||||
"providers": {},
|
||||
},
|
||||
|
||||
# Network settings — workarounds for connectivity issues.
|
||||
"network": {
|
||||
# Force IPv4 connections. On servers with broken or unreachable IPv6,
|
||||
@@ -995,6 +1030,13 @@ DEFAULT_CONFIG = {
|
||||
"min_interval_hours": 24,
|
||||
},
|
||||
|
||||
# Contextual first-touch onboarding hints (see agent/onboarding.py).
|
||||
# Each hint is shown once per install and then latched here so it
|
||||
# never fires again. Users can wipe the section to re-see all hints.
|
||||
"onboarding": {
|
||||
"seen": {},
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 22,
|
||||
}
|
||||
@@ -1553,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",
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
"""
|
||||
hermes fallback — manage the fallback provider chain.
|
||||
|
||||
Fallback providers are tried in order when the primary model fails with
|
||||
rate-limit, overload, or connection errors. See:
|
||||
https://hermes-agent.nousresearch.com/docs/user-guide/features/fallback-providers
|
||||
|
||||
Subcommands:
|
||||
hermes fallback [list] Show the current fallback chain (default when no subcommand)
|
||||
hermes fallback add Pick provider + model via the same picker as `hermes model`,
|
||||
then append the selection to the chain
|
||||
hermes fallback remove Pick an entry to delete from the chain
|
||||
hermes fallback clear Remove all fallback entries
|
||||
|
||||
Storage: ``fallback_providers`` in ``~/.hermes/config.yaml`` (top-level, list of
|
||||
``{provider, model, base_url?, api_mode?}`` dicts). The legacy single-dict
|
||||
``fallback_model`` format is migrated to the new list format on first add.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _read_chain(config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Return the normalized fallback chain as a list of dicts.
|
||||
|
||||
Accepts both the new list format (``fallback_providers``) and the legacy
|
||||
single-dict format (``fallback_model``). The returned list is always a
|
||||
fresh copy — callers can mutate without touching the config dict.
|
||||
"""
|
||||
chain = config.get("fallback_providers") or []
|
||||
if isinstance(chain, list):
|
||||
result = [dict(e) for e in chain if isinstance(e, dict) and e.get("provider") and e.get("model")]
|
||||
if result:
|
||||
return result
|
||||
legacy = config.get("fallback_model")
|
||||
if isinstance(legacy, dict) and legacy.get("provider") and legacy.get("model"):
|
||||
return [dict(legacy)]
|
||||
if isinstance(legacy, list):
|
||||
return [dict(e) for e in legacy if isinstance(e, dict) and e.get("provider") and e.get("model")]
|
||||
return []
|
||||
|
||||
|
||||
def _write_chain(config: Dict[str, Any], chain: List[Dict[str, Any]]) -> None:
|
||||
"""Persist the chain to ``fallback_providers`` and clear legacy key."""
|
||||
config["fallback_providers"] = chain
|
||||
# Drop the legacy single-dict key on write so there's only one source of truth.
|
||||
if "fallback_model" in config:
|
||||
config.pop("fallback_model", None)
|
||||
|
||||
|
||||
def _format_entry(entry: Dict[str, Any]) -> str:
|
||||
"""One-line human-readable rendering of a fallback entry."""
|
||||
provider = entry.get("provider", "?")
|
||||
model = entry.get("model", "?")
|
||||
base = entry.get("base_url")
|
||||
suffix = f" [{base}]" if base else ""
|
||||
return f"{model} (via {provider}){suffix}"
|
||||
|
||||
|
||||
def _extract_fallback_from_model_cfg(model_cfg: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Pull the ``{provider, model, base_url?, api_mode?}`` dict from a ``config["model"]`` snapshot."""
|
||||
if not isinstance(model_cfg, dict):
|
||||
return None
|
||||
provider = (model_cfg.get("provider") or "").strip()
|
||||
# The picker writes the selected model to ``model.default``.
|
||||
model = (model_cfg.get("default") or model_cfg.get("model") or "").strip()
|
||||
if not provider or not model:
|
||||
return None
|
||||
entry: Dict[str, Any] = {"provider": provider, "model": model}
|
||||
base_url = (model_cfg.get("base_url") or "").strip()
|
||||
if base_url:
|
||||
entry["base_url"] = base_url
|
||||
api_mode = (model_cfg.get("api_mode") or "").strip()
|
||||
if api_mode:
|
||||
entry["api_mode"] = api_mode
|
||||
return entry
|
||||
|
||||
|
||||
def _snapshot_auth_active_provider() -> Any:
|
||||
"""Return the current ``active_provider`` in auth.json, or a sentinel if unavailable."""
|
||||
try:
|
||||
from hermes_cli.auth import _load_auth_store
|
||||
store = _load_auth_store()
|
||||
return store.get("active_provider")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _restore_auth_active_provider(value: Any) -> None:
|
||||
"""Write back a previously snapshotted ``active_provider`` value."""
|
||||
try:
|
||||
from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store
|
||||
with _auth_store_lock():
|
||||
store = _load_auth_store()
|
||||
store["active_provider"] = value
|
||||
_save_auth_store(store)
|
||||
except Exception:
|
||||
# Best-effort — if auth.json can't be restored, the user's primary
|
||||
# provider may have been deactivated by the picker. They can re-run
|
||||
# `hermes model` to fix it. Don't fail the fallback add.
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subcommand handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_fallback_list(args) -> None: # noqa: ARG001
|
||||
"""Print the current fallback chain."""
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
config = load_config()
|
||||
chain = _read_chain(config)
|
||||
|
||||
print()
|
||||
if not chain:
|
||||
print(" No fallback providers configured.")
|
||||
print()
|
||||
print(" Add one with: hermes fallback add")
|
||||
print()
|
||||
return
|
||||
|
||||
primary = _describe_primary(config)
|
||||
if primary:
|
||||
print(f" Primary: {primary}")
|
||||
print()
|
||||
print(f" Fallback chain ({len(chain)} {'entry' if len(chain) == 1 else 'entries'}):")
|
||||
for i, entry in enumerate(chain, 1):
|
||||
print(f" {i}. {_format_entry(entry)}")
|
||||
print()
|
||||
print(" Tried in order when the primary fails (rate-limit, 5xx, connection errors).")
|
||||
print(" Docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/fallback-providers")
|
||||
print()
|
||||
|
||||
|
||||
def _describe_primary(config: Dict[str, Any]) -> Optional[str]:
|
||||
"""One-line description of the primary model for display purposes."""
|
||||
model_cfg = config.get("model")
|
||||
if isinstance(model_cfg, dict):
|
||||
provider = (model_cfg.get("provider") or "?").strip() or "?"
|
||||
model = (model_cfg.get("default") or model_cfg.get("model") or "?").strip() or "?"
|
||||
return f"{model} (via {provider})"
|
||||
if isinstance(model_cfg, str) and model_cfg.strip():
|
||||
return model_cfg.strip()
|
||||
return None
|
||||
|
||||
|
||||
def cmd_fallback_add(args) -> None:
|
||||
"""Launch the same picker as `hermes model`, then append the selection to the chain."""
|
||||
from hermes_cli.main import _require_tty, select_provider_and_model
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
_require_tty("fallback add")
|
||||
|
||||
# Snapshot BEFORE the picker runs so we can distinguish "user actually
|
||||
# picked something" from "user cancelled" by comparing before/after.
|
||||
before_cfg = load_config()
|
||||
model_before = copy.deepcopy(before_cfg.get("model"))
|
||||
active_provider_before = _snapshot_auth_active_provider()
|
||||
|
||||
print()
|
||||
print(" Adding a fallback provider. The picker below is the same one used by")
|
||||
print(" `hermes model` — select the provider + model you want as a fallback.")
|
||||
print()
|
||||
|
||||
try:
|
||||
select_provider_and_model(args=args)
|
||||
except SystemExit:
|
||||
# Some provider flows exit on auth failure — restore state and re-raise.
|
||||
_restore_model_cfg(model_before)
|
||||
_restore_auth_active_provider(active_provider_before)
|
||||
raise
|
||||
|
||||
# Read the post-picker state to see what the user selected.
|
||||
after_cfg = load_config()
|
||||
model_after = after_cfg.get("model")
|
||||
|
||||
new_entry = _extract_fallback_from_model_cfg(model_after)
|
||||
if not new_entry:
|
||||
# Picker didn't complete (user cancelled or flow bailed). Nothing to do.
|
||||
_restore_model_cfg(model_before)
|
||||
_restore_auth_active_provider(active_provider_before)
|
||||
print()
|
||||
print(" No fallback added.")
|
||||
return
|
||||
|
||||
# Picker picked the same thing that's already the primary → nothing changed,
|
||||
# and there's nothing useful to add as a fallback to itself.
|
||||
primary_entry = _extract_fallback_from_model_cfg(model_before)
|
||||
if primary_entry and primary_entry["provider"] == new_entry["provider"] \
|
||||
and primary_entry["model"] == new_entry["model"]:
|
||||
_restore_model_cfg(model_before)
|
||||
_restore_auth_active_provider(active_provider_before)
|
||||
print()
|
||||
print(f" Selected model matches the current primary ({_format_entry(new_entry)}).")
|
||||
print(" A provider cannot be a fallback for itself — no change.")
|
||||
return
|
||||
|
||||
# Reload the config with the primary restored, then append the new entry
|
||||
# to ``fallback_providers``. We deliberately re-load (rather than mutating
|
||||
# ``after_cfg``) because the picker may have touched other top-level keys
|
||||
# (custom_providers, providers credentials) that we want to keep.
|
||||
_restore_model_cfg(model_before)
|
||||
_restore_auth_active_provider(active_provider_before)
|
||||
|
||||
final_cfg = load_config()
|
||||
chain = _read_chain(final_cfg)
|
||||
|
||||
# Reject exact-duplicate fallback entries.
|
||||
for existing in chain:
|
||||
if existing.get("provider") == new_entry["provider"] \
|
||||
and existing.get("model") == new_entry["model"]:
|
||||
print()
|
||||
print(f" {_format_entry(new_entry)} is already in the fallback chain — skipped.")
|
||||
return
|
||||
|
||||
chain.append(new_entry)
|
||||
_write_chain(final_cfg, chain)
|
||||
save_config(final_cfg)
|
||||
|
||||
print()
|
||||
print(f" Added fallback: {_format_entry(new_entry)}")
|
||||
print(f" Chain is now {len(chain)} {'entry' if len(chain) == 1 else 'entries'} long.")
|
||||
print()
|
||||
print(" Run `hermes fallback list` to view, or `hermes fallback remove` to delete.")
|
||||
|
||||
|
||||
def _restore_model_cfg(model_before: Any) -> None:
|
||||
"""Restore ``config["model"]`` to a previously-captured snapshot."""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
if model_before is None:
|
||||
cfg.pop("model", None)
|
||||
else:
|
||||
cfg["model"] = copy.deepcopy(model_before)
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def cmd_fallback_remove(args) -> None: # noqa: ARG001
|
||||
"""Pick an entry from the chain and remove it."""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
config = load_config()
|
||||
chain = _read_chain(config)
|
||||
|
||||
if not chain:
|
||||
print()
|
||||
print(" No fallback providers configured — nothing to remove.")
|
||||
print()
|
||||
return
|
||||
|
||||
choices = [_format_entry(e) for e in chain]
|
||||
choices.append("Cancel")
|
||||
|
||||
try:
|
||||
from hermes_cli.setup import _curses_prompt_choice
|
||||
idx = _curses_prompt_choice("Select a fallback to remove:", choices, 0)
|
||||
except Exception:
|
||||
idx = _numbered_pick("Select a fallback to remove:", choices)
|
||||
|
||||
if idx is None or idx < 0 or idx >= len(chain):
|
||||
print()
|
||||
print(" Cancelled — no change.")
|
||||
return
|
||||
|
||||
removed = chain.pop(idx)
|
||||
_write_chain(config, chain)
|
||||
save_config(config)
|
||||
|
||||
print()
|
||||
print(f" Removed fallback: {_format_entry(removed)}")
|
||||
if chain:
|
||||
print(f" Chain is now {len(chain)} {'entry' if len(chain) == 1 else 'entries'} long.")
|
||||
else:
|
||||
print(" Fallback chain is now empty.")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_fallback_clear(args) -> None: # noqa: ARG001
|
||||
"""Remove all fallback entries (with confirmation)."""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
config = load_config()
|
||||
chain = _read_chain(config)
|
||||
|
||||
if not chain:
|
||||
print()
|
||||
print(" No fallback providers configured — nothing to clear.")
|
||||
print()
|
||||
return
|
||||
|
||||
print()
|
||||
print(f" Current fallback chain ({len(chain)} {'entry' if len(chain) == 1 else 'entries'}):")
|
||||
for i, entry in enumerate(chain, 1):
|
||||
print(f" {i}. {_format_entry(entry)}")
|
||||
print()
|
||||
try:
|
||||
resp = input(" Clear all entries? [y/N]: ").strip().lower()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
print(" Cancelled.")
|
||||
return
|
||||
if resp not in ("y", "yes"):
|
||||
print(" Cancelled — no change.")
|
||||
return
|
||||
|
||||
_write_chain(config, [])
|
||||
save_config(config)
|
||||
print()
|
||||
print(" Fallback chain cleared.")
|
||||
print()
|
||||
|
||||
|
||||
def _numbered_pick(question: str, choices: List[str]) -> Optional[int]:
|
||||
"""Fallback numbered-list picker when curses is unavailable."""
|
||||
print(question)
|
||||
for i, c in enumerate(choices, 1):
|
||||
print(f" {i}. {c}")
|
||||
print()
|
||||
while True:
|
||||
try:
|
||||
val = input(f"Choice [1-{len(choices)}]: ").strip()
|
||||
if not val:
|
||||
return None
|
||||
idx = int(val) - 1
|
||||
if 0 <= idx < len(choices):
|
||||
return idx
|
||||
print(f"Please enter 1-{len(choices)}")
|
||||
except ValueError:
|
||||
print("Please enter a number")
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_fallback(args) -> None:
|
||||
"""Top-level dispatcher for ``hermes fallback [subcommand]``."""
|
||||
sub = getattr(args, "fallback_command", None)
|
||||
if sub in (None, "", "list", "ls"):
|
||||
cmd_fallback_list(args)
|
||||
elif sub == "add":
|
||||
cmd_fallback_add(args)
|
||||
elif sub in ("remove", "rm"):
|
||||
cmd_fallback_remove(args)
|
||||
elif sub == "clear":
|
||||
cmd_fallback_clear(args)
|
||||
else:
|
||||
print(f"Unknown fallback subcommand: {sub}")
|
||||
print("Use one of: list, add, remove, clear")
|
||||
raise SystemExit(2)
|
||||
@@ -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():
|
||||
|
||||
+322
-15
@@ -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
|
||||
@@ -2315,13 +2316,13 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||
# The live /models endpoint returns hundreds of models; the curated list
|
||||
# shows only agentic models users recognize from OpenRouter.
|
||||
from hermes_cli.models import (
|
||||
_PROVIDER_MODELS,
|
||||
get_curated_nous_model_ids,
|
||||
get_pricing_for_provider,
|
||||
check_nous_free_tier,
|
||||
partition_nous_models_by_tier,
|
||||
)
|
||||
|
||||
model_ids = _PROVIDER_MODELS.get("nous", [])
|
||||
model_ids = get_curated_nous_model_ids()
|
||||
if not model_ids:
|
||||
print("No curated models available for Nous Portal.")
|
||||
return
|
||||
@@ -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
|
||||
@@ -7223,6 +7428,9 @@ Examples:
|
||||
hermes auth remove <p> <t> Remove pooled credential by index, id, or label
|
||||
hermes auth reset <provider> Clear exhaustion status for a provider
|
||||
hermes model Select default model
|
||||
hermes fallback [list] Show fallback provider chain
|
||||
hermes fallback add Add a fallback provider (same picker as `hermes model`)
|
||||
hermes fallback remove Remove a fallback provider from the chain
|
||||
hermes config View configuration
|
||||
hermes config edit Edit config in $EDITOR
|
||||
hermes config set model gpt-4 Set a config value
|
||||
@@ -7564,6 +7772,42 @@ For more help on a command:
|
||||
)
|
||||
model_parser.set_defaults(func=cmd_model)
|
||||
|
||||
# =========================================================================
|
||||
# fallback command — manage the fallback provider chain
|
||||
# =========================================================================
|
||||
from hermes_cli.fallback_cmd import cmd_fallback
|
||||
|
||||
fallback_parser = subparsers.add_parser(
|
||||
"fallback",
|
||||
help="Manage fallback providers (tried when the primary model fails)",
|
||||
description=(
|
||||
"Manage the fallback provider chain. Fallback providers are tried "
|
||||
"in order when the primary model fails with rate-limit, overload, or "
|
||||
"connection errors. See: "
|
||||
"https://hermes-agent.nousresearch.com/docs/user-guide/features/fallback-providers"
|
||||
),
|
||||
)
|
||||
fallback_subparsers = fallback_parser.add_subparsers(dest="fallback_command")
|
||||
fallback_subparsers.add_parser(
|
||||
"list",
|
||||
aliases=["ls"],
|
||||
help="Show the current fallback chain (default when no subcommand)",
|
||||
)
|
||||
fallback_subparsers.add_parser(
|
||||
"add",
|
||||
help="Pick a provider + model (same picker as `hermes model`) and append to the chain",
|
||||
)
|
||||
fallback_subparsers.add_parser(
|
||||
"remove",
|
||||
aliases=["rm"],
|
||||
help="Pick an entry to delete from the chain",
|
||||
)
|
||||
fallback_subparsers.add_parser(
|
||||
"clear",
|
||||
help="Remove all fallback entries",
|
||||
)
|
||||
fallback_parser.set_defaults(func=cmd_fallback)
|
||||
|
||||
# =========================================================================
|
||||
# gateway command
|
||||
# =========================================================================
|
||||
@@ -7759,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
|
||||
# =========================================================================
|
||||
@@ -8390,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"
|
||||
)
|
||||
@@ -8414,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"
|
||||
@@ -8920,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:
|
||||
@@ -9017,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.")
|
||||
@@ -9031,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":
|
||||
@@ -9049,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(
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Remote model catalog fetcher.
|
||||
|
||||
The Hermes docs site hosts a JSON manifest of curated models for providers
|
||||
we want to update without shipping a release (currently OpenRouter and
|
||||
Nous Portal). This module fetches, validates, and caches that manifest,
|
||||
falling back to the in-repo hardcoded lists when the network is unavailable.
|
||||
|
||||
Pipeline
|
||||
--------
|
||||
1. ``get_catalog()`` — returns a parsed manifest dict.
|
||||
- Checks in-process cache (invalidated by TTL).
|
||||
- Reads disk cache at ``~/.hermes/cache/model_catalog.json``.
|
||||
- Fetches the master URL if disk cache is stale or missing.
|
||||
- On any fetch failure, keeps using the stale cache (or empty dict).
|
||||
|
||||
2. ``get_curated_openrouter_models()`` / ``get_curated_nous_models()`` —
|
||||
thin accessors returning the shapes existing callers expect. Each
|
||||
falls back to the in-repo hardcoded list on any lookup failure.
|
||||
|
||||
Schema (version 1)
|
||||
------------------
|
||||
::
|
||||
|
||||
{
|
||||
"version": 1,
|
||||
"updated_at": "2026-04-25T22:00:00Z",
|
||||
"metadata": {...}, # free-form
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"metadata": {...}, # free-form
|
||||
"models": [
|
||||
{"id": "vendor/model", "description": "recommended",
|
||||
"metadata": {...}} # free-form, model-level
|
||||
]
|
||||
},
|
||||
"nous": {...}
|
||||
}
|
||||
}
|
||||
|
||||
Unknown fields are ignored — extra metadata can be added at either level
|
||||
without bumping ``version``. ``version`` bumps are reserved for
|
||||
breaking changes (renaming ``providers``, changing ``models`` shape).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hermes_cli import __version__ as _HERMES_VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_CATALOG_URL = (
|
||||
"https://hermes-agent.nousresearch.com/docs/api/model-catalog.json"
|
||||
)
|
||||
DEFAULT_TTL_HOURS = 24
|
||||
DEFAULT_FETCH_TIMEOUT = 8.0
|
||||
SUPPORTED_SCHEMA_VERSION = 1
|
||||
|
||||
_HERMES_USER_AGENT = f"hermes-cli/{_HERMES_VERSION}"
|
||||
|
||||
# In-process cache to avoid repeated disk + parse work across multiple
|
||||
# calls within the same session. Invalidated by TTL against the disk file's
|
||||
# mtime, so calling code never has to think about this.
|
||||
_catalog_cache: dict[str, Any] | None = None
|
||||
_catalog_cache_source_mtime: float = 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_catalog_config() -> dict[str, Any]:
|
||||
"""Load the ``model_catalog`` config block with defaults filled in."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config() or {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
|
||||
raw = cfg.get("model_catalog")
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
|
||||
return {
|
||||
"enabled": bool(raw.get("enabled", True)),
|
||||
"url": str(raw.get("url") or DEFAULT_CATALOG_URL),
|
||||
"ttl_hours": float(raw.get("ttl_hours") or DEFAULT_TTL_HOURS),
|
||||
"providers": raw.get("providers") if isinstance(raw.get("providers"), dict) else {},
|
||||
}
|
||||
|
||||
|
||||
def _cache_path() -> Path:
|
||||
"""Return the disk cache path. Import lazily so tests can monkeypatch home."""
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home() / "cache" / "model_catalog.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fetch + validate + cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fetch_manifest(url: str, timeout: float) -> dict[str, Any] | None:
|
||||
"""HTTP GET the manifest URL and return a parsed dict, or None on failure."""
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": _HERMES_USER_AGENT,
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc:
|
||||
logger.info("model catalog fetch failed (%s): %s", url, exc)
|
||||
return None
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
logger.info("model catalog fetch errored (%s): %s", url, exc)
|
||||
return None
|
||||
|
||||
if not _validate_manifest(data):
|
||||
logger.info("model catalog at %s failed schema validation", url)
|
||||
return None
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _validate_manifest(data: Any) -> bool:
|
||||
"""Return True when ``data`` matches the minimum manifest shape."""
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
version = data.get("version")
|
||||
if not isinstance(version, int) or version > SUPPORTED_SCHEMA_VERSION:
|
||||
# Future schema version we don't understand — refuse rather than
|
||||
# guess. Older schemas (version < 1) aren't supported either.
|
||||
return False
|
||||
providers = data.get("providers")
|
||||
if not isinstance(providers, dict):
|
||||
return False
|
||||
for pname, pblock in providers.items():
|
||||
if not isinstance(pname, str) or not isinstance(pblock, dict):
|
||||
return False
|
||||
models = pblock.get("models")
|
||||
if not isinstance(models, list):
|
||||
return False
|
||||
for m in models:
|
||||
if not isinstance(m, dict):
|
||||
return False
|
||||
if not isinstance(m.get("id"), str) or not m["id"].strip():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _read_disk_cache() -> tuple[dict[str, Any] | None, float]:
|
||||
"""Return ``(data_or_none, mtime)``. mtime is 0 if file is missing."""
|
||||
path = _cache_path()
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except (OSError, FileNotFoundError):
|
||||
return (None, 0.0)
|
||||
try:
|
||||
with open(path) as fh:
|
||||
data = json.load(fh)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return (None, 0.0)
|
||||
if not _validate_manifest(data):
|
||||
return (None, 0.0)
|
||||
return (data, mtime)
|
||||
|
||||
|
||||
def _write_disk_cache(data: dict[str, Any]) -> None:
|
||||
path = _cache_path()
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with open(tmp, "w") as fh:
|
||||
json.dump(data, fh, indent=2)
|
||||
fh.write("\n")
|
||||
os.replace(tmp, path)
|
||||
except OSError as exc:
|
||||
logger.info("model catalog cache write failed: %s", exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_catalog(*, force_refresh: bool = False) -> dict[str, Any]:
|
||||
"""Return the parsed model catalog manifest, or an empty dict on failure.
|
||||
|
||||
Callers should treat a missing provider/model as "use the in-repo fallback"
|
||||
— never raise from this function so the CLI keeps working offline.
|
||||
"""
|
||||
global _catalog_cache, _catalog_cache_source_mtime
|
||||
|
||||
cfg = _load_catalog_config()
|
||||
if not cfg["enabled"]:
|
||||
return {}
|
||||
|
||||
ttl_seconds = max(0.0, cfg["ttl_hours"] * 3600.0)
|
||||
|
||||
disk_data, disk_mtime = _read_disk_cache()
|
||||
now = time.time()
|
||||
disk_fresh = disk_data is not None and (now - disk_mtime) < ttl_seconds
|
||||
|
||||
# In-process cache hit: disk hasn't changed since we loaded it and still fresh.
|
||||
if (
|
||||
not force_refresh
|
||||
and _catalog_cache is not None
|
||||
and disk_data is not None
|
||||
and disk_mtime == _catalog_cache_source_mtime
|
||||
and disk_fresh
|
||||
):
|
||||
return _catalog_cache
|
||||
|
||||
# Disk is fresh enough — use it without a network hit.
|
||||
if not force_refresh and disk_fresh and disk_data is not None:
|
||||
_catalog_cache = disk_data
|
||||
_catalog_cache_source_mtime = disk_mtime
|
||||
return disk_data
|
||||
|
||||
# Need to (re)fetch. If it fails, fall back to any stale disk copy.
|
||||
fetched = _fetch_manifest(cfg["url"], DEFAULT_FETCH_TIMEOUT)
|
||||
if fetched is not None:
|
||||
_write_disk_cache(fetched)
|
||||
new_disk_data, new_mtime = _read_disk_cache()
|
||||
if new_disk_data is not None:
|
||||
_catalog_cache = new_disk_data
|
||||
_catalog_cache_source_mtime = new_mtime
|
||||
return new_disk_data
|
||||
_catalog_cache = fetched
|
||||
_catalog_cache_source_mtime = now
|
||||
return fetched
|
||||
|
||||
if disk_data is not None:
|
||||
_catalog_cache = disk_data
|
||||
_catalog_cache_source_mtime = disk_mtime
|
||||
return disk_data
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def _fetch_provider_override(provider: str) -> dict[str, Any] | None:
|
||||
"""If ``model_catalog.providers.<name>.url`` is set, fetch that instead."""
|
||||
cfg = _load_catalog_config()
|
||||
if not cfg["enabled"]:
|
||||
return None
|
||||
provider_cfg = cfg["providers"].get(provider)
|
||||
if not isinstance(provider_cfg, dict):
|
||||
return None
|
||||
override_url = provider_cfg.get("url")
|
||||
if not isinstance(override_url, str) or not override_url.strip():
|
||||
return None
|
||||
# Override fetches skip the disk cache because they're usually
|
||||
# third-party self-hosted. Re-request on every call but with a short
|
||||
# timeout so they don't block the picker.
|
||||
return _fetch_manifest(override_url.strip(), DEFAULT_FETCH_TIMEOUT)
|
||||
|
||||
|
||||
def _get_provider_block(provider: str) -> dict[str, Any] | None:
|
||||
"""Return the provider's manifest block, respecting per-provider overrides."""
|
||||
override = _fetch_provider_override(provider)
|
||||
if override is not None:
|
||||
block = override.get("providers", {}).get(provider)
|
||||
if isinstance(block, dict):
|
||||
return block
|
||||
|
||||
catalog = get_catalog()
|
||||
if not catalog:
|
||||
return None
|
||||
block = catalog.get("providers", {}).get(provider)
|
||||
return block if isinstance(block, dict) else None
|
||||
|
||||
|
||||
def get_curated_openrouter_models() -> list[tuple[str, str]] | None:
|
||||
"""Return OpenRouter's curated ``[(id, description), ...]`` from the manifest.
|
||||
|
||||
Returns ``None`` when the manifest is unavailable, so callers can fall
|
||||
back to their hardcoded list.
|
||||
"""
|
||||
block = _get_provider_block("openrouter")
|
||||
if not block:
|
||||
return None
|
||||
out: list[tuple[str, str]] = []
|
||||
for m in block.get("models", []):
|
||||
mid = str(m.get("id") or "").strip()
|
||||
if not mid:
|
||||
continue
|
||||
desc = str(m.get("description") or "")
|
||||
out.append((mid, desc))
|
||||
return out or None
|
||||
|
||||
|
||||
def get_curated_nous_models() -> list[str] | None:
|
||||
"""Return Nous Portal's curated list of model ids from the manifest.
|
||||
|
||||
Returns ``None`` when the manifest is unavailable.
|
||||
"""
|
||||
block = _get_provider_block("nous")
|
||||
if not block:
|
||||
return None
|
||||
out: list[str] = []
|
||||
for m in block.get("models", []):
|
||||
mid = str(m.get("id") or "").strip()
|
||||
if mid:
|
||||
out.append(mid)
|
||||
return out or None
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Clear the in-process cache. Used by tests and ``hermes model --refresh``."""
|
||||
global _catalog_cache, _catalog_cache_source_mtime
|
||||
_catalog_cache = None
|
||||
_catalog_cache_source_mtime = 0.0
|
||||
+28
-5
@@ -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",
|
||||
@@ -876,7 +872,16 @@ def fetch_openrouter_models(
|
||||
if _openrouter_catalog_cache is not None and not force_refresh:
|
||||
return list(_openrouter_catalog_cache)
|
||||
|
||||
fallback = list(OPENROUTER_MODELS)
|
||||
# Prefer the remotely-hosted catalog manifest; fall back to the in-repo
|
||||
# snapshot when the manifest is unreachable. Both are curated lists that
|
||||
# drive the picker; the OpenRouter live /v1/models filter (tool support,
|
||||
# free pricing) is applied on top either way.
|
||||
try:
|
||||
from hermes_cli.model_catalog import get_curated_openrouter_models
|
||||
remote = get_curated_openrouter_models()
|
||||
except Exception:
|
||||
remote = None
|
||||
fallback = list(remote) if remote else list(OPENROUTER_MODELS)
|
||||
preferred_ids = [mid for mid, _ in fallback]
|
||||
|
||||
try:
|
||||
@@ -929,6 +934,24 @@ def model_ids(*, force_refresh: bool = False) -> list[str]:
|
||||
return [mid for mid, _ in fetch_openrouter_models(force_refresh=force_refresh)]
|
||||
|
||||
|
||||
def get_curated_nous_model_ids() -> list[str]:
|
||||
"""Return the curated Nous Portal model-id list.
|
||||
|
||||
Prefers the remotely-hosted catalog manifest (published under
|
||||
``website/static/api/model-catalog.json``); falls back to the in-repo
|
||||
snapshot in ``_PROVIDER_MODELS["nous"]`` when the manifest is
|
||||
unreachable. Always returns a list (never None).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.model_catalog import get_curated_nous_models
|
||||
remote = get_curated_nous_models()
|
||||
except Exception:
|
||||
remote = None
|
||||
if remote:
|
||||
return list(remote)
|
||||
return list(_PROVIDER_MODELS.get("nous", []))
|
||||
|
||||
|
||||
def _ai_gateway_model_is_free(pricing: Any) -> bool:
|
||||
"""Return True if an AI Gateway model has $0 input AND output pricing."""
|
||||
if not isinstance(pricing, dict):
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
+2
-3
@@ -10,8 +10,7 @@ import random
|
||||
|
||||
TIPS = [
|
||||
# --- Slash Commands ---
|
||||
"/btw <question> asks a quick side question without tools or history — great for clarifications.",
|
||||
"/background <prompt> runs a task in a separate session while your current one stays free.",
|
||||
"/background <prompt> (alias /bg or /btw) runs a task in a separate session while your current one stays free.",
|
||||
"/branch forks the current session so you can explore a different direction without losing progress.",
|
||||
"/compress manually compresses conversation context when things get long.",
|
||||
"/rollback lists filesystem checkpoints — restore files the agent modified to any prior state.",
|
||||
@@ -107,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
|
||||
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, useApp } from 'ink';
|
||||
import { VirtualizedMessageContainer } from './VirtualizedMessageContainer';
|
||||
import { usePerformanceMonitor } from './performanceHooks';
|
||||
|
||||
// This is a proof-of-concept component to demonstrate the performance fixes
|
||||
export const AppLayoutOptimized: React.FC = () => {
|
||||
const { stdout } = useApp();
|
||||
const { metrics, measureOperation } = usePerformanceMonitor('AppLayout', {
|
||||
logToConsole: true
|
||||
});
|
||||
|
||||
// Calculate viewport dimensions based on terminal size
|
||||
const viewportHeight = stdout.rows - 4; // Reserve space for input, etc.
|
||||
const viewportWidth = stdout.columns;
|
||||
|
||||
// In a real implementation, messages would come from app state
|
||||
const messages = React.useMemo(() => {
|
||||
return Array(1000).fill(null).map((_, index) => ({
|
||||
id: `msg-${index}`,
|
||||
role: index % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `This is message ${index}. It contains some content that might wrap to multiple lines depending on the terminal width. This demonstrates how virtualization can significantly improve performance.`,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height={stdout.rows} width={stdout.columns}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
height={viewportHeight}
|
||||
width={viewportWidth}
|
||||
overflow="hidden"
|
||||
// Use stable scrollbar gutter to prevent layout shifts
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
>
|
||||
<VirtualizedMessageContainer
|
||||
messages={messages}
|
||||
height={viewportHeight}
|
||||
width={viewportWidth}
|
||||
expandCode={true}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Performance metrics display */}
|
||||
<Box marginTop={1}>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor="yellow"
|
||||
paddingX={1}
|
||||
width={viewportWidth}
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Box width={25}>Avg render time:</Box>
|
||||
<Box>{metrics.averageRenderTime.toFixed(2)}ms</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box width={25}>Total renders:</Box>
|
||||
<Box>{metrics.totalRenders}</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box width={25}>Slow renders:</Box>
|
||||
<Box>{metrics.slowRenders}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,147 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { MessageData } from '../gatewayTypes';
|
||||
import { Markdown } from './markdown';
|
||||
import { themed } from './themed';
|
||||
|
||||
// Estimated average height for message rows (will be refined later)
|
||||
const ESTIMATED_ROW_HEIGHT = 50;
|
||||
|
||||
// Overscan count - render this many items above/below the visible area
|
||||
const OVERSCAN_COUNT = 10;
|
||||
|
||||
interface MessageLineProps {
|
||||
message: MessageData;
|
||||
onRender?: () => void;
|
||||
isHighlighted?: boolean;
|
||||
expandCode?: boolean;
|
||||
}
|
||||
|
||||
export const MessageLine: React.FC<MessageLineProps> = React.memo(({
|
||||
message,
|
||||
onRender,
|
||||
isHighlighted = false,
|
||||
expandCode = false
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { role, content } = message;
|
||||
|
||||
useEffect(() => {
|
||||
onRender?.();
|
||||
}, [onRender]);
|
||||
|
||||
// Skip rendering for empty messages
|
||||
if (!content) return null;
|
||||
|
||||
const RoleLabel = themed(Text, {
|
||||
user: theme.message.user.label,
|
||||
assistant: theme.message.assistant.label,
|
||||
system: theme.message.system.label,
|
||||
tool: theme.message.tool.label,
|
||||
function: theme.message.function.label,
|
||||
});
|
||||
|
||||
const roleStyles = {
|
||||
user: theme.message.user.content,
|
||||
assistant: theme.message.assistant.content,
|
||||
system: theme.message.system.content,
|
||||
tool: theme.message.tool.content,
|
||||
function: theme.message.function.content,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={0}
|
||||
paddingY={0}
|
||||
borderStyle={isHighlighted ? 'bold' : undefined}
|
||||
borderColor={isHighlighted ? theme.focused : undefined}
|
||||
>
|
||||
<Box>
|
||||
<RoleLabel variant={role as any}>{role}:</RoleLabel>
|
||||
</Box>
|
||||
<Box marginLeft={1}>
|
||||
<Markdown
|
||||
variant={role as keyof typeof roleStyles}
|
||||
content={content || ''}
|
||||
expandCode={expandCode}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Custom comparison logic for memoization
|
||||
return (
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.content === nextProps.message.content &&
|
||||
prevProps.message.role === nextProps.message.role &&
|
||||
prevProps.isHighlighted === nextProps.isHighlighted &&
|
||||
prevProps.expandCode === nextProps.expandCode
|
||||
);
|
||||
});
|
||||
|
||||
interface MessageContainerProps {
|
||||
messages: MessageData[];
|
||||
height: number;
|
||||
width: number;
|
||||
expandCode?: boolean;
|
||||
highlightedMessageId?: string;
|
||||
}
|
||||
|
||||
export const VirtualizedMessageContainer: React.FC<MessageContainerProps> = ({
|
||||
messages,
|
||||
height,
|
||||
width,
|
||||
expandCode = false,
|
||||
highlightedMessageId,
|
||||
}) => {
|
||||
const listRef = useRef<List>(null);
|
||||
const [measuredHeights, setMeasuredHeights] = useState<Record<string, number>>({});
|
||||
|
||||
// Scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
if (listRef.current && messages.length > 0) {
|
||||
listRef.current.scrollToItem(messages.length - 1);
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
// Record the actual rendered heights for more accurate virtualization
|
||||
const handleMessageRender = (id: string, index: number) => {
|
||||
// In a real implementation, we would measure DOM nodes here
|
||||
// This is a placeholder for the concept
|
||||
if (!measuredHeights[id]) {
|
||||
setMeasuredHeights(prev => ({
|
||||
...prev,
|
||||
[id]: ESTIMATED_ROW_HEIGHT // In reality, we'd measure the actual height
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<List
|
||||
ref={listRef}
|
||||
height={height}
|
||||
width={width}
|
||||
itemCount={messages.length}
|
||||
itemSize={ESTIMATED_ROW_HEIGHT}
|
||||
overscanCount={OVERSCAN_COUNT}
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
>
|
||||
{({ index, style }) => {
|
||||
const message = messages[index];
|
||||
return (
|
||||
<div style={style}>
|
||||
<MessageLine
|
||||
message={message}
|
||||
expandCode={expandCode}
|
||||
isHighlighted={message.id === highlightedMessageId}
|
||||
onRender={() => handleMessageRender(message.id, index)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
@@ -1,188 +0,0 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { MessageData } from '../gatewayTypes';
|
||||
import { Markdown } from './markdown';
|
||||
import { themed } from './themed';
|
||||
import { usePerformanceMonitor, useScrollPerformance } from '../hooks/performanceHooks';
|
||||
|
||||
// Optimize the MessageLine component with proper memoization
|
||||
export const MessageLine: React.FC<{
|
||||
message: MessageData;
|
||||
isHighlighted?: boolean;
|
||||
expandCode?: boolean;
|
||||
}> = React.memo(({ message, isHighlighted = false, expandCode = false }) => {
|
||||
const theme = useTheme();
|
||||
const { role, content } = message;
|
||||
const { logEvent } = usePerformanceMonitor(`MessageLine-${role.substring(0,1)}${message.id?.substring(0,4)}`);
|
||||
|
||||
// Skip rendering for empty messages
|
||||
if (!content) return null;
|
||||
|
||||
const RoleLabel = themed(Text, {
|
||||
user: theme.message.user.label,
|
||||
assistant: theme.message.assistant.label,
|
||||
system: theme.message.system.label,
|
||||
tool: theme.message.tool.label,
|
||||
function: theme.message.function.label,
|
||||
});
|
||||
|
||||
const roleStyles = {
|
||||
user: theme.message.user.content,
|
||||
assistant: theme.message.assistant.content,
|
||||
system: theme.message.system.content,
|
||||
tool: theme.message.tool.content,
|
||||
function: theme.message.function.content,
|
||||
};
|
||||
|
||||
// Log initial render for performance monitoring
|
||||
useEffect(() => {
|
||||
logEvent('initial-render');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={0}
|
||||
paddingY={0}
|
||||
borderStyle={isHighlighted ? 'bold' : undefined}
|
||||
borderColor={isHighlighted ? theme.focused : undefined}
|
||||
>
|
||||
<Box>
|
||||
<RoleLabel variant={role as any}>{role}:</RoleLabel>
|
||||
</Box>
|
||||
<Box marginLeft={1}>
|
||||
<Markdown
|
||||
variant={role as keyof typeof roleStyles}
|
||||
content={content || ''}
|
||||
expandCode={expandCode}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Custom comparison to prevent unnecessary re-renders
|
||||
return (
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.content === nextProps.message.content &&
|
||||
prevProps.message.role === nextProps.message.role &&
|
||||
prevProps.isHighlighted === nextProps.isHighlighted &&
|
||||
prevProps.expandCode === nextProps.expandCode
|
||||
);
|
||||
});
|
||||
|
||||
// Fixed window approach for rendering only visible + buffer messages
|
||||
export const MessageContainer: React.FC<{
|
||||
messages: MessageData[];
|
||||
scrollBuffer?: number;
|
||||
expandCode?: boolean;
|
||||
highlightedMessageId?: string;
|
||||
}> = ({ messages, scrollBuffer = 50, expandCode = false, highlightedMessageId }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { onScroll } = useScrollPerformance('MessageContainer');
|
||||
const { logEvent } = usePerformanceMonitor('MessageContainer');
|
||||
|
||||
// Track visible range
|
||||
const [visibleRange, setVisibleRange] = useState({
|
||||
start: Math.max(0, messages.length - 30),
|
||||
end: messages.length
|
||||
});
|
||||
|
||||
// Handle scroll events to update visible range
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
const scrollRatio = scrollTop / (scrollHeight - clientHeight);
|
||||
|
||||
// Calculate visible range based on scroll position
|
||||
const totalMessages = messages.length;
|
||||
const visibleCount = 30; // Approximate number of visible messages
|
||||
const bufferSize = scrollBuffer;
|
||||
|
||||
// Calculate start/end indices
|
||||
const middleIndex = Math.floor(scrollRatio * totalMessages);
|
||||
const halfVisible = Math.floor(visibleCount / 2);
|
||||
|
||||
let start = Math.max(0, middleIndex - halfVisible - bufferSize);
|
||||
let end = Math.min(totalMessages, middleIndex + halfVisible + bufferSize);
|
||||
|
||||
// Special case for start/end of list
|
||||
if (scrollRatio < 0.1) {
|
||||
start = 0;
|
||||
end = Math.min(totalMessages, visibleCount + bufferSize);
|
||||
} else if (scrollRatio > 0.9) {
|
||||
end = totalMessages;
|
||||
start = Math.max(0, totalMessages - visibleCount - bufferSize);
|
||||
}
|
||||
|
||||
setVisibleRange({ start, end });
|
||||
|
||||
// Performance monitoring
|
||||
onScroll();
|
||||
}, [messages.length, scrollBuffer, onScroll]);
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50;
|
||||
|
||||
if (isNearBottom) {
|
||||
// Only auto-scroll if we're already near the bottom
|
||||
logEvent('auto-scroll');
|
||||
containerRef.current.scrollTop = scrollHeight;
|
||||
|
||||
// Update visible range to show bottom messages
|
||||
setVisibleRange({
|
||||
start: Math.max(0, messages.length - 30 - scrollBuffer),
|
||||
end: messages.length
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [messages.length, scrollBuffer]);
|
||||
|
||||
// Log rendering details
|
||||
useEffect(() => {
|
||||
logEvent(`render-range-${visibleRange.start}-${visibleRange.end}`);
|
||||
}, [visibleRange]);
|
||||
|
||||
// Get visible messages subset
|
||||
const visibleMessages = messages.slice(visibleRange.start, visibleRange.end);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
overflow="auto"
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
style={{ scrollbarGutter: 'stable both-edges' }}
|
||||
>
|
||||
{/* Spacer for scroll position */}
|
||||
{visibleRange.start > 0 && (
|
||||
<Box
|
||||
height={visibleRange.start * 3}
|
||||
width="100%"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Visible messages */}
|
||||
{visibleMessages.map((message) => (
|
||||
<MessageLine
|
||||
key={message.id}
|
||||
message={message}
|
||||
expandCode={expandCode}
|
||||
isHighlighted={message.id === highlightedMessageId}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Spacer for remaining messages */}
|
||||
{visibleRange.end < messages.length && (
|
||||
<Box
|
||||
height={(messages.length - visibleRange.end) * 3}
|
||||
width="100%"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,207 +0,0 @@
|
||||
import { useRef, useCallback, useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook for performance monitoring
|
||||
* Helps track and log performance metrics for components
|
||||
*/
|
||||
export function usePerformanceMonitor(componentName: string, options = {
|
||||
logToConsole: false,
|
||||
thresholdMs: 16 // 60fps threshold
|
||||
}) {
|
||||
const renderCountRef = useRef(0);
|
||||
const renderTimesRef = useRef<number[]>([]);
|
||||
const lastRenderTimeRef = useRef(performance.now());
|
||||
const [metrics, setMetrics] = useState({
|
||||
averageRenderTime: 0,
|
||||
totalRenders: 0,
|
||||
slowRenders: 0
|
||||
});
|
||||
|
||||
// Measure start of render cycle
|
||||
useEffect(() => {
|
||||
const startTime = performance.now();
|
||||
|
||||
return () => {
|
||||
const endTime = performance.now();
|
||||
const renderTime = endTime - startTime;
|
||||
|
||||
renderCountRef.current += 1;
|
||||
renderTimesRef.current.push(renderTime);
|
||||
|
||||
// Keep only the last 100 measurements
|
||||
if (renderTimesRef.current.length > 100) {
|
||||
renderTimesRef.current.shift();
|
||||
}
|
||||
|
||||
// Calculate average render time
|
||||
const average = renderTimesRef.current.reduce((sum, time) => sum + time, 0) /
|
||||
renderTimesRef.current.length;
|
||||
|
||||
// Count slow renders
|
||||
const slowRenders = renderTimesRef.current.filter(time => time > options.thresholdMs).length;
|
||||
|
||||
// Update metrics
|
||||
setMetrics({
|
||||
averageRenderTime: average,
|
||||
totalRenders: renderCountRef.current,
|
||||
slowRenders
|
||||
});
|
||||
|
||||
if (options.logToConsole && renderTime > options.thresholdMs) {
|
||||
console.log(
|
||||
`[PERF] ${componentName} render: ${renderTime.toFixed(2)}ms ` +
|
||||
`(avg: ${average.toFixed(2)}ms, slow: ${slowRenders}/${renderCountRef.current})`
|
||||
);
|
||||
}
|
||||
|
||||
lastRenderTimeRef.current = endTime;
|
||||
};
|
||||
});
|
||||
|
||||
// Function to measure specific operations
|
||||
const measureOperation = useCallback((operationName: string, fn: () => void) => {
|
||||
const start = performance.now();
|
||||
fn();
|
||||
const duration = performance.now() - start;
|
||||
|
||||
if (options.logToConsole && duration > options.thresholdMs) {
|
||||
console.log(`[PERF] ${componentName}.${operationName}: ${duration.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
return duration;
|
||||
}, [componentName, options.logToConsole, options.thresholdMs]);
|
||||
|
||||
return {
|
||||
metrics,
|
||||
measureOperation,
|
||||
logEvent: (event: string, durationMs?: number) => {
|
||||
if (options.logToConsole) {
|
||||
const message = durationMs
|
||||
? `[PERF] ${componentName}.${event}: ${durationMs.toFixed(2)}ms`
|
||||
: `[PERF] ${componentName}.${event}`;
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to debounce frequent updates
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to throttle frequent updates
|
||||
*/
|
||||
export function useThrottle<T>(value: T, limit: number): T {
|
||||
const [throttledValue, setThrottledValue] = useState<T>(value);
|
||||
const lastRan = useRef(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
if (Date.now() - lastRan.current >= limit) {
|
||||
setThrottledValue(value);
|
||||
lastRan.current = Date.now();
|
||||
}
|
||||
}, limit - (Date.now() - lastRan.current));
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, limit]);
|
||||
|
||||
return throttledValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to measure and track scroll performance
|
||||
*/
|
||||
export function useScrollPerformance(componentName: string, options = {
|
||||
logToConsole: false,
|
||||
sampleRate: 0.1, // Only log 10% of scroll events to reduce noise
|
||||
thresholdMs: 16
|
||||
}) {
|
||||
const scrollCountRef = useRef(0);
|
||||
const scrollTimesRef = useRef<number[]>([]);
|
||||
const isScrollingRef = useRef(false);
|
||||
const scrollStartTimeRef = useRef(0);
|
||||
const scrollThrottleTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const onScrollStart = useCallback(() => {
|
||||
if (!isScrollingRef.current) {
|
||||
isScrollingRef.current = true;
|
||||
scrollStartTimeRef.current = performance.now();
|
||||
|
||||
if (options.logToConsole) {
|
||||
console.log(`[SCROLL] ${componentName} scroll started`);
|
||||
}
|
||||
}
|
||||
}, [componentName, options.logToConsole]);
|
||||
|
||||
const onScrollEnd = useCallback(() => {
|
||||
if (isScrollingRef.current) {
|
||||
const duration = performance.now() - scrollStartTimeRef.current;
|
||||
scrollTimesRef.current.push(duration);
|
||||
|
||||
// Keep array at reasonable size
|
||||
if (scrollTimesRef.current.length > 50) {
|
||||
scrollTimesRef.current.shift();
|
||||
}
|
||||
|
||||
isScrollingRef.current = false;
|
||||
|
||||
if (options.logToConsole && Math.random() < options.sampleRate) {
|
||||
const avg = scrollTimesRef.current.reduce((sum, time) => sum + time, 0) /
|
||||
scrollTimesRef.current.length;
|
||||
|
||||
console.log(
|
||||
`[SCROLL] ${componentName} scroll ended: ${duration.toFixed(2)}ms ` +
|
||||
`(avg: ${avg.toFixed(2)}ms)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [componentName, options.logToConsole, options.sampleRate]);
|
||||
|
||||
const onScroll = useCallback(() => {
|
||||
scrollCountRef.current += 1;
|
||||
|
||||
// Start scrolling tracking if not already
|
||||
onScrollStart();
|
||||
|
||||
// Reset the scroll end timer
|
||||
if (scrollThrottleTimerRef.current) {
|
||||
clearTimeout(scrollThrottleTimerRef.current);
|
||||
}
|
||||
|
||||
// Set timer to detect when scrolling stops
|
||||
scrollThrottleTimerRef.current = setTimeout(() => {
|
||||
onScrollEnd();
|
||||
}, 150); // Consider scrolling stopped after 150ms of inactivity
|
||||
|
||||
}, [onScrollStart, onScrollEnd]);
|
||||
|
||||
// Clean up
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollThrottleTimerRef.current) {
|
||||
clearTimeout(scrollThrottleTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { onScroll };
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
# TUI Performance Analysis
|
||||
|
||||
## Issues Identified
|
||||
|
||||
1. **Scrolling lag with large message history**
|
||||
- No virtualization or windowing in message rendering
|
||||
- Each message re-renders on scroll
|
||||
- Complete DOM reconstruction on each render
|
||||
|
||||
2. **Input jitter with scrollbar**
|
||||
- Composer width changes when scrollbar appears/disappears
|
||||
- Layout shifts when scrolling near bottom
|
||||
|
||||
3. **Layout thrashing**
|
||||
- Multiple successive layout recalculations
|
||||
- Excessive style computations in the render loop
|
||||
|
||||
## Investigation Areas
|
||||
|
||||
### 1. Message Rendering Performance
|
||||
|
||||
Current implementation in `messageLine.tsx` renders all messages in the transcript without virtualization. For long sessions, this means:
|
||||
|
||||
- Every message is always in the DOM
|
||||
- Complete re-rendering happens on each state change
|
||||
- No windowing or culling of off-screen content
|
||||
- Layout recalculations for entire transcript on each scroll
|
||||
|
||||
### 2. Re-rendering Optimization
|
||||
|
||||
- No memoization of message components
|
||||
- No element recycling
|
||||
- Each message potentially triggers layout shifts
|
||||
|
||||
### 3. Scrollbar Behavior
|
||||
|
||||
- Composer width calculation doesn't account for scrollbar presence
|
||||
- No stable layout constraints
|
||||
|
||||
## Proposed Solutions
|
||||
|
||||
### 1. Implement Virtualized List for Messages
|
||||
|
||||
Add `react-window` or similar virtualization library to render only visible messages:
|
||||
|
||||
```tsx
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
|
||||
// In the component render
|
||||
<List
|
||||
height={viewportHeight}
|
||||
itemCount={messages.length}
|
||||
itemSize={estimatedRowHeight}
|
||||
width="100%"
|
||||
overscanCount={5}
|
||||
>
|
||||
{({ index, style }) => (
|
||||
<div style={style}>
|
||||
<MessageLine message={messages[index]} />
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
```
|
||||
|
||||
### 2. Memoize Message Components
|
||||
|
||||
Use `React.memo` to prevent unnecessary re-renders:
|
||||
|
||||
```tsx
|
||||
const MessageLine = React.memo(({ message, ...props }) => {
|
||||
// Component logic
|
||||
}, (prevProps, nextProps) => {
|
||||
// Custom comparison logic
|
||||
return prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.content === nextProps.message.content;
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Fix Scrollbar Layout Issues
|
||||
|
||||
- Add scrollbar-gutter CSS to reserve space for scrollbar
|
||||
- Stabilize layout with fixed container dimensions
|
||||
|
||||
```css
|
||||
.message-container {
|
||||
scrollbar-gutter: stable;
|
||||
overflow-y: auto;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Add Performance Measurements
|
||||
|
||||
Add performance monitoring to identify bottlenecks:
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
const start = performance.now();
|
||||
// Measure key operations
|
||||
return () => {
|
||||
console.log(`Operation took ${performance.now() - start}ms`);
|
||||
};
|
||||
}, [dependencyArray]);
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. Add virtualization for message rendering
|
||||
2. Implement memo optimization for components
|
||||
3. Fix scrollbar layout issues
|
||||
4. Add performance monitoring
|
||||
5. Optimize re-render triggers
|
||||
6. Improve scroll restoration
|
||||
|
||||
## Resources
|
||||
|
||||
- [React Window](https://github.com/bvaughn/react-window)
|
||||
- [React Virtualized](https://github.com/bvaughn/react-virtualized)
|
||||
- [CSS Scrollbar Gutter](https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-gutter)
|
||||
@@ -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:
|
||||
|
||||
+131
-40
@@ -892,7 +892,6 @@ class AIAgent:
|
||||
checkpoints_enabled: bool = False,
|
||||
checkpoint_max_snapshots: int = 50,
|
||||
pass_session_id: bool = False,
|
||||
persist_session: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize the AI Agent.
|
||||
@@ -964,7 +963,6 @@ class AIAgent:
|
||||
self.background_review_callback = None # Optional sync callback for gateway delivery
|
||||
self.skip_context_files = skip_context_files
|
||||
self.pass_session_id = pass_session_id
|
||||
self.persist_session = persist_session
|
||||
self._credential_pool = credential_pool
|
||||
self.log_prefix_chars = log_prefix_chars
|
||||
self.log_prefix = f"{log_prefix} " if log_prefix else ""
|
||||
@@ -3109,13 +3107,28 @@ class AIAgent:
|
||||
)
|
||||
|
||||
_SKILL_REVIEW_PROMPT = (
|
||||
"Review the conversation above and consider saving or updating a skill if appropriate.\n\n"
|
||||
"Focus on: was a non-trivial approach used to complete a task that required trial "
|
||||
"and error, or changing course due to experiential findings along the way, or did "
|
||||
"the user expect or desire a different method or outcome?\n\n"
|
||||
"If a relevant skill already exists, update it with what you learned. "
|
||||
"Otherwise, create a new skill if the approach is reusable.\n"
|
||||
"If nothing is worth saving, just say 'Nothing to save.' and stop."
|
||||
"Review the conversation above and consider whether a skill should be saved or updated.\n\n"
|
||||
"Work in this order — do not skip steps:\n\n"
|
||||
"1. SURVEY the existing skill landscape first. Call skills_list to see what you "
|
||||
"have. If anything looks potentially relevant, skill_view it before deciding. "
|
||||
"You are looking for the CLASS of task that just happened, not the exact task. "
|
||||
"Example: a successful Tauri build is in the class \"desktop app build "
|
||||
"troubleshooting\", not \"fix my specific Tauri error today\".\n\n"
|
||||
"2. THINK CLASS-FIRST. What general pattern of task did the user just complete? "
|
||||
"What conditions will trigger this pattern again? Describe the class in one "
|
||||
"sentence before looking at what to save.\n\n"
|
||||
"3. PREFER GENERALIZING AN EXISTING SKILL over creating a new one. If a skill "
|
||||
"already covers the class — even partially — update it (skill_manage patch) "
|
||||
"with the new insight. Broaden its \"when to use\" trigger if needed.\n\n"
|
||||
"4. ONLY CREATE A NEW SKILL when no existing skill reasonably covers the class. "
|
||||
"When you create one, name and scope it at the class level "
|
||||
"(\"react-i18n-setup\", not \"add-i18n-to-my-dashboard-app\"). The trigger "
|
||||
"section must describe the class of situations, not this one session.\n\n"
|
||||
"5. If you notice two existing skills that overlap, note it in your response "
|
||||
"so a future review can consolidate them. Do not consolidate now unless the "
|
||||
"overlap is obvious and low-risk.\n\n"
|
||||
"Only act when something is genuinely worth saving. "
|
||||
"If nothing stands out, just say 'Nothing to save.' and stop."
|
||||
)
|
||||
|
||||
_COMBINED_REVIEW_PROMPT = (
|
||||
@@ -3125,9 +3138,16 @@ class AIAgent:
|
||||
"about how you should behave, their work style, or ways they want you to operate? "
|
||||
"If so, save using the memory tool.\n\n"
|
||||
"**Skills**: Was a non-trivial approach used to complete a task that required trial "
|
||||
"and error, or changing course due to experiential findings along the way, or did "
|
||||
"the user expect or desire a different method or outcome? If a relevant skill "
|
||||
"already exists, update it. Otherwise, create a new one if the approach is reusable.\n\n"
|
||||
"and error, changing course due to experiential findings, or a different method "
|
||||
"or outcome than the user expected? If so, work in this order:\n"
|
||||
" a. SURVEY existing skills first (skills_list, then skill_view on candidates).\n"
|
||||
" b. Identify the CLASS of task, not the specific task "
|
||||
"(\"desktop app build troubleshooting\", not \"fix my Tauri error\").\n"
|
||||
" c. PREFER UPDATING/GENERALIZING an existing skill that covers the class.\n"
|
||||
" d. ONLY CREATE A NEW SKILL if no existing one covers the class. Scope at "
|
||||
"the class level, not this one session.\n"
|
||||
" e. If you notice overlapping skills during the survey, note it so a future "
|
||||
"review can consolidate them.\n\n"
|
||||
"Only act if there's something genuinely worth saving. "
|
||||
"If nothing stands out, just say 'Nothing to save.' and stop."
|
||||
)
|
||||
@@ -3225,12 +3245,25 @@ class AIAgent:
|
||||
with open(os.devnull, "w") as _devnull, \
|
||||
contextlib.redirect_stdout(_devnull), \
|
||||
contextlib.redirect_stderr(_devnull):
|
||||
# Inherit the parent agent's live runtime (provider, model,
|
||||
# base_url, api_key, api_mode) so the fork uses the exact
|
||||
# same credentials the main turn is using. Without this,
|
||||
# AIAgent.__init__ re-runs auto-resolution from env vars,
|
||||
# which fails for OAuth-only providers, session-scoped
|
||||
# creds, or credential-pool setups where the resolver can't
|
||||
# reconstruct auth from scratch -- producing the spurious
|
||||
# "No LLM provider configured" warning at end of turn.
|
||||
_parent_runtime = self._current_main_runtime()
|
||||
review_agent = AIAgent(
|
||||
model=self.model,
|
||||
max_iterations=8,
|
||||
quiet_mode=True,
|
||||
platform=self.platform,
|
||||
provider=self.provider,
|
||||
api_mode=_parent_runtime.get("api_mode") or None,
|
||||
base_url=_parent_runtime.get("base_url") or None,
|
||||
api_key=_parent_runtime.get("api_key") or None,
|
||||
credential_pool=getattr(self, "_credential_pool", None),
|
||||
parent_session_id=self.session_id,
|
||||
)
|
||||
review_agent._memory_write_origin = "background_review"
|
||||
@@ -3271,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:
|
||||
@@ -3331,10 +3373,7 @@ class AIAgent:
|
||||
"""Save session state to both JSON log and SQLite on any exit path.
|
||||
|
||||
Ensures conversations are never lost, even on errors or early returns.
|
||||
Skipped when ``persist_session=False`` (ephemeral helper flows).
|
||||
"""
|
||||
if not self.persist_session:
|
||||
return
|
||||
self._apply_persist_user_message_override(messages)
|
||||
self._session_messages = messages
|
||||
self._save_session_log(messages)
|
||||
@@ -7851,7 +7890,17 @@ class AIAgent:
|
||||
api_msg["reasoning_content"] = existing
|
||||
return
|
||||
|
||||
# 2. DeepSeek / Kimi thinking mode: tool-call turns that lack
|
||||
# 2. Healthy session: promote 'reasoning' field to 'reasoning_content'
|
||||
# for providers that use the internal 'reasoning' key.
|
||||
# This must happen BEFORE the DeepSeek/Kimi tool-call check so that
|
||||
# genuine reasoning content is not overwritten by the empty-string
|
||||
# fallback (#15812 regression in PR #15478).
|
||||
normalized_reasoning = source_msg.get("reasoning")
|
||||
if isinstance(normalized_reasoning, str) and normalized_reasoning:
|
||||
api_msg["reasoning_content"] = normalized_reasoning
|
||||
return
|
||||
|
||||
# 3. DeepSeek / Kimi thinking mode: tool-call turns that lack
|
||||
# reasoning_content are "poisoned history" — a prior provider (MiniMax,
|
||||
# etc.) left them empty. DeepSeek returns HTTP 400 if reasoning_content
|
||||
# is absent on replay; inject "" to satisfy the provider's requirement
|
||||
@@ -7867,13 +7916,6 @@ class AIAgent:
|
||||
api_msg["reasoning_content"] = ""
|
||||
return
|
||||
|
||||
# 3. Healthy session: promote 'reasoning' field to 'reasoning_content'
|
||||
# for providers that use the internal 'reasoning' key.
|
||||
normalized_reasoning = source_msg.get("reasoning")
|
||||
if isinstance(normalized_reasoning, str) and normalized_reasoning:
|
||||
api_msg["reasoning_content"] = normalized_reasoning
|
||||
return
|
||||
|
||||
# 4. DeepSeek / Kimi thinking mode: all assistant messages need
|
||||
# reasoning_content. Inject "" to satisfy the provider's requirement
|
||||
# when no explicit reasoning content is present.
|
||||
@@ -8118,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:
|
||||
@@ -11007,36 +11065,69 @@ class AIAgent:
|
||||
continue
|
||||
|
||||
# ── Nous Portal: record rate limit & skip retries ─────
|
||||
# When Nous returns a 429, record the reset time to a
|
||||
# shared file so ALL sessions (cron, gateway, auxiliary)
|
||||
# know not to pile on. Then skip further retries —
|
||||
# each one burns another RPH request and deepens the
|
||||
# rate limit hole. The retry loop's top-of-iteration
|
||||
# guard will catch this on the next pass and try
|
||||
# fallback or bail with a clear message.
|
||||
# When Nous returns a 429 that is a genuine account-
|
||||
# level rate limit, record the reset time to a shared
|
||||
# file so ALL sessions (cron, gateway, auxiliary) know
|
||||
# not to pile on, then skip further retries -- each
|
||||
# one burns another RPH request and deepens the hole.
|
||||
# The retry loop's top-of-iteration guard will catch
|
||||
# this on the next pass and try fallback or bail.
|
||||
#
|
||||
# IMPORTANT: Nous Portal multiplexes multiple upstream
|
||||
# providers (DeepSeek, Kimi, MiMo, Hermes). A 429 can
|
||||
# also mean an UPSTREAM provider is out of capacity
|
||||
# for one specific model -- transient, clears in
|
||||
# seconds, nothing to do with the caller's quota.
|
||||
# Tripping the cross-session breaker on that would
|
||||
# block every Nous model for minutes. We use
|
||||
# ``is_genuine_nous_rate_limit`` to tell the two
|
||||
# apart via the 429's own x-ratelimit-* headers and
|
||||
# the last-known-good state captured on the previous
|
||||
# successful response.
|
||||
if (
|
||||
is_rate_limited
|
||||
and self.provider == "nous"
|
||||
and classified.reason == FailoverReason.rate_limit
|
||||
and not recovered_with_pool
|
||||
):
|
||||
_genuine_nous_rate_limit = False
|
||||
try:
|
||||
from agent.nous_rate_guard import record_nous_rate_limit
|
||||
from agent.nous_rate_guard import (
|
||||
is_genuine_nous_rate_limit,
|
||||
record_nous_rate_limit,
|
||||
)
|
||||
_err_resp = getattr(api_error, "response", None)
|
||||
_err_hdrs = (
|
||||
getattr(_err_resp, "headers", None)
|
||||
if _err_resp else None
|
||||
)
|
||||
record_nous_rate_limit(
|
||||
_genuine_nous_rate_limit = is_genuine_nous_rate_limit(
|
||||
headers=_err_hdrs,
|
||||
error_context=error_context,
|
||||
last_known_state=self._rate_limit_state,
|
||||
)
|
||||
if _genuine_nous_rate_limit:
|
||||
record_nous_rate_limit(
|
||||
headers=_err_hdrs,
|
||||
error_context=error_context,
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
"Nous 429 looks like upstream capacity "
|
||||
"(no exhausted bucket in headers or "
|
||||
"last-known state) -- not tripping "
|
||||
"cross-session breaker."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Skip straight to max_retries — the top-of-loop
|
||||
# guard will handle fallback or bail cleanly.
|
||||
retry_count = max_retries
|
||||
continue
|
||||
if _genuine_nous_rate_limit:
|
||||
# Skip straight to max_retries -- the
|
||||
# top-of-loop guard will handle fallback or
|
||||
# bail cleanly.
|
||||
retry_count = max_retries
|
||||
continue
|
||||
# Upstream capacity 429: fall through to normal
|
||||
# retry logic. A different model (or the same
|
||||
# model a moment later) will typically succeed.
|
||||
|
||||
is_payload_too_large = (
|
||||
classified.reason == FailoverReason.payload_too_large
|
||||
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the Hermes Model Catalog — a centralized JSON manifest of curated models.
|
||||
|
||||
This script reads the in-repo hardcoded curated lists (``OPENROUTER_MODELS``,
|
||||
``_PROVIDER_MODELS["nous"]``) and writes them to a JSON manifest that the
|
||||
Hermes CLI fetches at runtime. Publishing the catalog through the docs site
|
||||
lets maintainers update model lists without shipping a Hermes release.
|
||||
|
||||
The runtime fetcher falls back to the same in-repo hardcoded lists if the
|
||||
manifest is unreachable, so this script is a convenience for keeping the
|
||||
manifest in sync — not a source of truth.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/build_model_catalog.py
|
||||
|
||||
Output: ``website/static/api/model-catalog.json``
|
||||
|
||||
Live URL (after ``deploy-site.yml`` runs on merge to main):
|
||||
``https://hermes-agent.nousresearch.com/docs/api/model-catalog.json``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, REPO_ROOT)
|
||||
|
||||
# Ensure HERMES_HOME is set for imports that touch it at module level.
|
||||
os.environ.setdefault("HERMES_HOME", os.path.join(os.path.expanduser("~"), ".hermes"))
|
||||
|
||||
from hermes_cli.models import OPENROUTER_MODELS, _PROVIDER_MODELS # noqa: E402
|
||||
|
||||
OUTPUT_PATH = os.path.join(REPO_ROOT, "website", "static", "api", "model-catalog.json")
|
||||
CATALOG_VERSION = 1
|
||||
|
||||
|
||||
def build_catalog() -> dict:
|
||||
return {
|
||||
"version": CATALOG_VERSION,
|
||||
"updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"metadata": {
|
||||
"source": "hermes-agent repo",
|
||||
"docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog",
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"metadata": {
|
||||
"display_name": "OpenRouter",
|
||||
"note": (
|
||||
"Descriptions drive picker badges. Live /api/v1/models "
|
||||
"filters curated ids by tool-calling support and free pricing."
|
||||
),
|
||||
},
|
||||
"models": [
|
||||
{"id": mid, "description": desc}
|
||||
for mid, desc in OPENROUTER_MODELS
|
||||
],
|
||||
},
|
||||
"nous": {
|
||||
"metadata": {
|
||||
"display_name": "Nous Portal",
|
||||
"note": (
|
||||
"Free-tier gating is determined live via Portal pricing "
|
||||
"(partition_nous_models_by_tier), not this manifest."
|
||||
),
|
||||
},
|
||||
"models": [
|
||||
{"id": mid}
|
||||
for mid in _PROVIDER_MODELS.get("nous", [])
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
catalog = build_catalog()
|
||||
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
|
||||
with open(OUTPUT_PATH, "w") as fh:
|
||||
json.dump(catalog, fh, indent=2)
|
||||
fh.write("\n")
|
||||
|
||||
print(f"Wrote {OUTPUT_PATH}")
|
||||
for provider, block in catalog["providers"].items():
|
||||
print(f" {provider}: {len(block['models'])} models")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+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,15 +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",
|
||||
@@ -66,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",
|
||||
@@ -115,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",
|
||||
@@ -190,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",
|
||||
@@ -376,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",
|
||||
|
||||
@@ -281,7 +281,6 @@ Type these during an interactive chat session.
|
||||
### Utility
|
||||
```
|
||||
/branch (/fork) Branch the current session
|
||||
/btw Ephemeral side question (doesn't interrupt main task)
|
||||
/fast Toggle priority/fast processing
|
||||
/browser Open CDP browser connection
|
||||
/history Show conversation history (CLI)
|
||||
@@ -403,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`
|
||||
@@ -192,6 +192,43 @@ class TestDefaultContextLengths:
|
||||
f"{model_id}: expected {expected_ctx}, got {actual}"
|
||||
)
|
||||
|
||||
def test_deepseek_v4_models_1m_context(self):
|
||||
from agent.model_metadata import get_model_context_length
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
expected_keys = {
|
||||
"deepseek-v4-pro": 1_000_000,
|
||||
"deepseek-v4-flash": 1_000_000,
|
||||
"deepseek-chat": 1_000_000,
|
||||
"deepseek-reasoner": 1_000_000,
|
||||
}
|
||||
for key, value in expected_keys.items():
|
||||
assert key in DEFAULT_CONTEXT_LENGTHS, f"{key} missing"
|
||||
assert DEFAULT_CONTEXT_LENGTHS[key] == value, (
|
||||
f"{key} should be {value}, got {DEFAULT_CONTEXT_LENGTHS[key]}"
|
||||
)
|
||||
|
||||
# Longest-first substring matching must resolve both the bare V4
|
||||
# ids (native DeepSeek) and the vendor-prefixed forms (OpenRouter
|
||||
# / Nous Portal) to 1M without probing down to the legacy 128K
|
||||
# ``deepseek`` substring fallback.
|
||||
with mock_patch("agent.model_metadata.fetch_model_metadata", return_value={}), \
|
||||
mock_patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), \
|
||||
mock_patch("agent.model_metadata.get_cached_context_length", return_value=None):
|
||||
cases = [
|
||||
("deepseek-v4-pro", 1_000_000),
|
||||
("deepseek-v4-flash", 1_000_000),
|
||||
("deepseek/deepseek-v4-pro", 1_000_000),
|
||||
("deepseek/deepseek-v4-flash", 1_000_000),
|
||||
("deepseek-chat", 1_000_000),
|
||||
("deepseek-reasoner", 1_000_000),
|
||||
]
|
||||
for model_id, expected_ctx in cases:
|
||||
actual = get_model_context_length(model_id)
|
||||
assert actual == expected_ctx, (
|
||||
f"{model_id}: expected {expected_ctx}, got {actual}"
|
||||
)
|
||||
|
||||
def test_all_values_positive(self):
|
||||
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
|
||||
assert value > 0, f"{key} has non-positive context length"
|
||||
@@ -303,7 +340,9 @@ class TestCodexOAuthContextLength:
|
||||
from agent.model_metadata import get_model_context_length
|
||||
|
||||
# OpenRouter — should hit its own catalog path first; when mocked
|
||||
# empty, falls through to hardcoded DEFAULT_CONTEXT_LENGTHS (400k).
|
||||
# empty, falls through to hardcoded DEFAULT_CONTEXT_LENGTHS (1.05M,
|
||||
# matching the real direct-API value — Codex OAuth's 272k cap is
|
||||
# provider-specific and must not leak here).
|
||||
with patch("agent.model_metadata.fetch_model_metadata", return_value={}), \
|
||||
patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), \
|
||||
patch("agent.model_metadata.get_cached_context_length", return_value=None), \
|
||||
@@ -314,7 +353,7 @@ class TestCodexOAuthContextLength:
|
||||
api_key="",
|
||||
provider="openrouter",
|
||||
)
|
||||
assert ctx == 400_000, (
|
||||
assert ctx == 1_050_000, (
|
||||
f"Non-Codex gpt-5.5 resolved to {ctx}; Codex 272k override "
|
||||
"leaked outside openai-codex provider"
|
||||
)
|
||||
|
||||
@@ -251,3 +251,141 @@ class TestAuxiliaryClientIntegration:
|
||||
monkeypatch.setattr(aux, "_read_nous_auth", lambda: None)
|
||||
result = aux._try_nous()
|
||||
assert result == (None, None)
|
||||
|
||||
|
||||
class TestIsGenuineNousRateLimit:
|
||||
"""Tell a real account-level 429 apart from an upstream-capacity 429.
|
||||
|
||||
Nous Portal multiplexes upstreams (DeepSeek, Kimi, MiMo, Hermes).
|
||||
A 429 from an upstream out of capacity should NOT trip the
|
||||
cross-session breaker; a real user-quota 429 should.
|
||||
"""
|
||||
|
||||
def test_exhausted_hourly_bucket_in_429_headers_is_genuine(self):
|
||||
from agent.nous_rate_guard import is_genuine_nous_rate_limit
|
||||
|
||||
headers = {
|
||||
"x-ratelimit-limit-requests-1h": "800",
|
||||
"x-ratelimit-remaining-requests-1h": "0",
|
||||
"x-ratelimit-reset-requests-1h": "3100",
|
||||
"x-ratelimit-limit-requests": "200",
|
||||
"x-ratelimit-remaining-requests": "198",
|
||||
"x-ratelimit-reset-requests": "40",
|
||||
}
|
||||
assert is_genuine_nous_rate_limit(headers=headers) is True
|
||||
|
||||
def test_exhausted_tokens_bucket_is_genuine(self):
|
||||
from agent.nous_rate_guard import is_genuine_nous_rate_limit
|
||||
|
||||
headers = {
|
||||
"x-ratelimit-limit-tokens": "800000",
|
||||
"x-ratelimit-remaining-tokens": "0",
|
||||
"x-ratelimit-reset-tokens": "45", # < 60s threshold -> not genuine
|
||||
"x-ratelimit-limit-tokens-1h": "8000000",
|
||||
"x-ratelimit-remaining-tokens-1h": "0",
|
||||
"x-ratelimit-reset-tokens-1h": "1800", # >= 60s threshold -> genuine
|
||||
}
|
||||
assert is_genuine_nous_rate_limit(headers=headers) is True
|
||||
|
||||
def test_healthy_headers_on_429_are_upstream_capacity(self):
|
||||
# Classic upstream-capacity symptom: Nous edge reports plenty of
|
||||
# headroom on every bucket, but returns 429 anyway because
|
||||
# upstream (DeepSeek / Kimi / ...) is out of capacity.
|
||||
from agent.nous_rate_guard import is_genuine_nous_rate_limit
|
||||
|
||||
headers = {
|
||||
"x-ratelimit-limit-requests": "200",
|
||||
"x-ratelimit-remaining-requests": "198",
|
||||
"x-ratelimit-reset-requests": "40",
|
||||
"x-ratelimit-limit-requests-1h": "800",
|
||||
"x-ratelimit-remaining-requests-1h": "750",
|
||||
"x-ratelimit-reset-requests-1h": "3100",
|
||||
"x-ratelimit-limit-tokens": "800000",
|
||||
"x-ratelimit-remaining-tokens": "790000",
|
||||
"x-ratelimit-reset-tokens": "40",
|
||||
"x-ratelimit-limit-tokens-1h": "8000000",
|
||||
"x-ratelimit-remaining-tokens-1h": "7800000",
|
||||
"x-ratelimit-reset-tokens-1h": "3100",
|
||||
}
|
||||
assert is_genuine_nous_rate_limit(headers=headers) is False
|
||||
|
||||
def test_bare_429_with_no_headers_is_upstream(self):
|
||||
from agent.nous_rate_guard import is_genuine_nous_rate_limit
|
||||
|
||||
assert is_genuine_nous_rate_limit(headers=None) is False
|
||||
assert is_genuine_nous_rate_limit(headers={}) is False
|
||||
assert is_genuine_nous_rate_limit(
|
||||
headers={"content-type": "application/json"}
|
||||
) is False
|
||||
|
||||
def test_exhausted_bucket_with_short_reset_is_not_genuine(self):
|
||||
# remaining == 0 but reset in < 60s: almost certainly a
|
||||
# secondary per-minute throttle that will clear immediately --
|
||||
# not worth tripping the cross-session breaker.
|
||||
from agent.nous_rate_guard import is_genuine_nous_rate_limit
|
||||
|
||||
headers = {
|
||||
"x-ratelimit-limit-requests": "200",
|
||||
"x-ratelimit-remaining-requests": "0",
|
||||
"x-ratelimit-reset-requests": "30",
|
||||
}
|
||||
assert is_genuine_nous_rate_limit(headers=headers) is False
|
||||
|
||||
def test_last_known_state_with_exhausted_bucket_triggers_genuine(self):
|
||||
# Headers on the 429 lack rate-limit info, but the previous
|
||||
# successful response already showed the hourly bucket
|
||||
# exhausted -- the 429 is almost certainly that limit
|
||||
# continuing.
|
||||
from agent.nous_rate_guard import is_genuine_nous_rate_limit
|
||||
from agent.rate_limit_tracker import parse_rate_limit_headers
|
||||
|
||||
prior_headers = {
|
||||
"x-ratelimit-limit-requests-1h": "800",
|
||||
"x-ratelimit-remaining-requests-1h": "0",
|
||||
"x-ratelimit-reset-requests-1h": "2000",
|
||||
"x-ratelimit-limit-requests": "200",
|
||||
"x-ratelimit-remaining-requests": "100",
|
||||
"x-ratelimit-reset-requests": "30",
|
||||
"x-ratelimit-limit-tokens": "800000",
|
||||
"x-ratelimit-remaining-tokens": "700000",
|
||||
"x-ratelimit-reset-tokens": "30",
|
||||
"x-ratelimit-limit-tokens-1h": "8000000",
|
||||
"x-ratelimit-remaining-tokens-1h": "7000000",
|
||||
"x-ratelimit-reset-tokens-1h": "2000",
|
||||
}
|
||||
last_state = parse_rate_limit_headers(prior_headers, provider="nous")
|
||||
assert is_genuine_nous_rate_limit(
|
||||
headers=None, last_known_state=last_state
|
||||
) is True
|
||||
|
||||
def test_last_known_state_all_healthy_stays_upstream(self):
|
||||
# Prior state was healthy; bare 429 arrives; should be treated
|
||||
# as upstream capacity.
|
||||
from agent.nous_rate_guard import is_genuine_nous_rate_limit
|
||||
from agent.rate_limit_tracker import parse_rate_limit_headers
|
||||
|
||||
prior_headers = {
|
||||
"x-ratelimit-limit-requests-1h": "800",
|
||||
"x-ratelimit-remaining-requests-1h": "750",
|
||||
"x-ratelimit-reset-requests-1h": "2000",
|
||||
"x-ratelimit-limit-requests": "200",
|
||||
"x-ratelimit-remaining-requests": "180",
|
||||
"x-ratelimit-reset-requests": "30",
|
||||
"x-ratelimit-limit-tokens": "800000",
|
||||
"x-ratelimit-remaining-tokens": "790000",
|
||||
"x-ratelimit-reset-tokens": "30",
|
||||
"x-ratelimit-limit-tokens-1h": "8000000",
|
||||
"x-ratelimit-remaining-tokens-1h": "7900000",
|
||||
"x-ratelimit-reset-tokens-1h": "2000",
|
||||
}
|
||||
last_state = parse_rate_limit_headers(prior_headers, provider="nous")
|
||||
assert is_genuine_nous_rate_limit(
|
||||
headers=None, last_known_state=last_state
|
||||
) is False
|
||||
|
||||
def test_none_last_state_and_no_headers_is_upstream(self):
|
||||
from agent.nous_rate_guard import is_genuine_nous_rate_limit
|
||||
|
||||
assert is_genuine_nous_rate_limit(
|
||||
headers=None, last_known_state=None
|
||||
) is False
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Tests for agent/onboarding.py — contextual first-touch hint helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import yaml
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class TestIsSeen:
|
||||
def test_empty_config_unseen(self):
|
||||
assert is_seen({}, BUSY_INPUT_FLAG) is False
|
||||
|
||||
def test_missing_onboarding_unseen(self):
|
||||
assert is_seen({"display": {}}, BUSY_INPUT_FLAG) is False
|
||||
|
||||
def test_onboarding_not_dict_unseen(self):
|
||||
assert is_seen({"onboarding": "nope"}, BUSY_INPUT_FLAG) is False
|
||||
|
||||
def test_seen_dict_missing_flag(self):
|
||||
assert is_seen({"onboarding": {"seen": {}}}, BUSY_INPUT_FLAG) is False
|
||||
|
||||
def test_seen_flag_true(self):
|
||||
cfg = {"onboarding": {"seen": {BUSY_INPUT_FLAG: True}}}
|
||||
assert is_seen(cfg, BUSY_INPUT_FLAG) is True
|
||||
|
||||
def test_seen_flag_falsy(self):
|
||||
cfg = {"onboarding": {"seen": {BUSY_INPUT_FLAG: False}}}
|
||||
assert is_seen(cfg, BUSY_INPUT_FLAG) is False
|
||||
|
||||
def test_other_flags_isolated(self):
|
||||
cfg = {"onboarding": {"seen": {BUSY_INPUT_FLAG: True}}}
|
||||
assert is_seen(cfg, TOOL_PROGRESS_FLAG) is False
|
||||
|
||||
|
||||
class TestMarkSeen:
|
||||
def test_creates_missing_file_and_sets_flag(self, tmp_path):
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True
|
||||
|
||||
loaded = yaml.safe_load(cfg_path.read_text())
|
||||
assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True
|
||||
|
||||
def test_preserves_other_config(self, tmp_path):
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
cfg_path.write_text(yaml.safe_dump({
|
||||
"model": {"default": "claude-sonnet-4.6"},
|
||||
"display": {"skin": "default"},
|
||||
}))
|
||||
|
||||
assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True
|
||||
loaded = yaml.safe_load(cfg_path.read_text())
|
||||
|
||||
assert loaded["model"]["default"] == "claude-sonnet-4.6"
|
||||
assert loaded["display"]["skin"] == "default"
|
||||
assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True
|
||||
|
||||
def test_preserves_other_seen_flags(self, tmp_path):
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
cfg_path.write_text(yaml.safe_dump({
|
||||
"onboarding": {"seen": {TOOL_PROGRESS_FLAG: True}},
|
||||
}))
|
||||
|
||||
assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True
|
||||
loaded = yaml.safe_load(cfg_path.read_text())
|
||||
|
||||
assert loaded["onboarding"]["seen"][TOOL_PROGRESS_FLAG] is True
|
||||
assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True
|
||||
|
||||
def test_idempotent(self, tmp_path):
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
mark_seen(cfg_path, BUSY_INPUT_FLAG)
|
||||
first = cfg_path.read_text()
|
||||
|
||||
# Second call must be a no-op on-disk content (file may be touched,
|
||||
# but the YAML contents should be identical).
|
||||
mark_seen(cfg_path, BUSY_INPUT_FLAG)
|
||||
second = cfg_path.read_text()
|
||||
|
||||
assert yaml.safe_load(first) == yaml.safe_load(second)
|
||||
|
||||
def test_handles_non_dict_onboarding(self, tmp_path):
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
cfg_path.write_text(yaml.safe_dump({"onboarding": "corrupted"}))
|
||||
|
||||
assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True
|
||||
loaded = yaml.safe_load(cfg_path.read_text())
|
||||
assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True
|
||||
|
||||
def test_handles_non_dict_seen(self, tmp_path):
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
cfg_path.write_text(yaml.safe_dump({"onboarding": {"seen": "corrupted"}}))
|
||||
|
||||
assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True
|
||||
loaded = yaml.safe_load(cfg_path.read_text())
|
||||
assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True
|
||||
|
||||
|
||||
class TestHintMessages:
|
||||
def test_busy_input_hint_gateway_interrupt(self):
|
||||
msg = busy_input_hint_gateway("interrupt")
|
||||
assert "/busy queue" in msg
|
||||
assert "interrupted" in msg.lower()
|
||||
|
||||
def test_busy_input_hint_gateway_queue(self):
|
||||
msg = busy_input_hint_gateway("queue")
|
||||
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
|
||||
|
||||
def test_busy_input_hint_cli_queue(self):
|
||||
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()
|
||||
|
||||
def test_hints_are_not_empty(self):
|
||||
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(),
|
||||
):
|
||||
assert hint.strip()
|
||||
|
||||
|
||||
class TestRoundTrip:
|
||||
"""After mark_seen, is_seen on the re-loaded config must return True."""
|
||||
|
||||
def test_mark_then_is_seen(self, tmp_path):
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
|
||||
assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True
|
||||
loaded = yaml.safe_load(cfg_path.read_text())
|
||||
|
||||
assert is_seen(loaded, BUSY_INPUT_FLAG) is True
|
||||
assert is_seen(loaded, TOOL_PROGRESS_FLAG) is False
|
||||
|
||||
def test_mark_both_flags_independently(self, tmp_path):
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
|
||||
mark_seen(cfg_path, BUSY_INPUT_FLAG)
|
||||
mark_seen(cfg_path, TOOL_PROGRESS_FLAG)
|
||||
loaded = yaml.safe_load(cfg_path.read_text())
|
||||
|
||||
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."""
|
||||
@@ -349,3 +434,121 @@ class TestBusySessionAck:
|
||||
|
||||
result = await runner._handle_active_session_busy_message(event, sk)
|
||||
assert result is False # not handled, let default path try
|
||||
|
||||
|
||||
class TestBusySessionOnboardingHint:
|
||||
"""First-touch hint appended to the busy-ack the first time it fires."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_busy_ack_appends_interrupt_hint(self, tmp_path, monkeypatch):
|
||||
"""First busy-while-running message gets an extra hint about /busy."""
|
||||
import gateway.run as _gr
|
||||
|
||||
monkeypatch.setattr(_gr, "_hermes_home", tmp_path)
|
||||
# mark_seen imports utils.atomic_yaml_write; make sure it resolves
|
||||
# against a writable dir by pointing _hermes_home at tmp_path.
|
||||
monkeypatch.setattr(_gr, "_load_gateway_config", lambda: {})
|
||||
|
||||
runner, _sentinel = _make_runner()
|
||||
runner._busy_input_mode = "interrupt"
|
||||
adapter = _make_adapter()
|
||||
|
||||
event = _make_event(text="ping")
|
||||
sk = build_session_key(event.source)
|
||||
|
||||
agent = MagicMock()
|
||||
agent.get_activity_summary.return_value = {
|
||||
"api_call_count": 3, "max_iterations": 60,
|
||||
"current_tool": None, "last_activity_ts": time.time(),
|
||||
"last_activity_desc": "api", "seconds_since_activity": 0.1,
|
||||
}
|
||||
runner._running_agents[sk] = agent
|
||||
runner._running_agents_ts[sk] = time.time() - 5
|
||||
runner.adapters[event.source.platform] = adapter
|
||||
|
||||
await runner._handle_active_session_busy_message(event, sk)
|
||||
|
||||
call_kwargs = adapter._send_with_retry.call_args
|
||||
content = call_kwargs.kwargs.get("content", "")
|
||||
|
||||
# Normal ack body
|
||||
assert "Interrupting" in content
|
||||
# First-touch hint appended
|
||||
assert "First-time tip" in content
|
||||
assert "/busy queue" in content
|
||||
|
||||
# The flag is now persisted to tmp_path/config.yaml
|
||||
import yaml
|
||||
cfg = yaml.safe_load((tmp_path / "config.yaml").read_text())
|
||||
assert cfg["onboarding"]["seen"]["busy_input_prompt"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_busy_ack_omits_hint(self, tmp_path, monkeypatch):
|
||||
"""Once the flag is marked, the hint never appears again."""
|
||||
import gateway.run as _gr
|
||||
import yaml
|
||||
|
||||
monkeypatch.setattr(_gr, "_hermes_home", tmp_path)
|
||||
# Pre-populate the config so is_seen() returns True from the start.
|
||||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||||
"onboarding": {"seen": {"busy_input_prompt": True}},
|
||||
}))
|
||||
monkeypatch.setattr(
|
||||
_gr, "_load_gateway_config",
|
||||
lambda: yaml.safe_load((tmp_path / "config.yaml").read_text()),
|
||||
)
|
||||
|
||||
runner, _sentinel = _make_runner()
|
||||
runner._busy_input_mode = "interrupt"
|
||||
adapter = _make_adapter()
|
||||
|
||||
event = _make_event(text="ping again")
|
||||
sk = build_session_key(event.source)
|
||||
|
||||
agent = MagicMock()
|
||||
agent.get_activity_summary.return_value = {
|
||||
"api_call_count": 3, "max_iterations": 60,
|
||||
"current_tool": None, "last_activity_ts": time.time(),
|
||||
"last_activity_desc": "api", "seconds_since_activity": 0.1,
|
||||
}
|
||||
runner._running_agents[sk] = agent
|
||||
runner._running_agents_ts[sk] = time.time() - 5
|
||||
runner.adapters[event.source.platform] = adapter
|
||||
|
||||
await runner._handle_active_session_busy_message(event, sk)
|
||||
|
||||
call_kwargs = adapter._send_with_retry.call_args
|
||||
content = call_kwargs.kwargs.get("content", "")
|
||||
|
||||
assert "Interrupting" in content
|
||||
assert "First-time tip" not in content
|
||||
assert "/busy queue" not in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queue_mode_hint_points_to_interrupt(self, tmp_path, monkeypatch):
|
||||
"""In queue mode the hint should suggest /busy interrupt, not /busy queue."""
|
||||
import gateway.run as _gr
|
||||
|
||||
monkeypatch.setattr(_gr, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(_gr, "_load_gateway_config", lambda: {})
|
||||
|
||||
runner, _sentinel = _make_runner()
|
||||
runner._busy_input_mode = "queue"
|
||||
adapter = _make_adapter()
|
||||
|
||||
event = _make_event(text="queue me")
|
||||
sk = build_session_key(event.source)
|
||||
runner.adapters[event.source.platform] = adapter
|
||||
|
||||
agent = MagicMock()
|
||||
runner._running_agents[sk] = agent
|
||||
|
||||
with patch("gateway.run.merge_pending_message_event"):
|
||||
await runner._handle_active_session_busy_message(event, sk)
|
||||
|
||||
content = adapter._send_with_retry.call_args.kwargs.get("content", "")
|
||||
assert "Queued for the next turn" in content
|
||||
assert "First-time tip" in content
|
||||
assert "/busy interrupt" in content
|
||||
# Must NOT tell the user to /busy queue when they're already on queue.
|
||||
assert "/busy queue" not in content
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Tests for interrupt-aware tool-progress suppression in gateway.
|
||||
|
||||
When a user sends `stop` while the agent is executing a batch of parallel
|
||||
tool calls, the gateway's progress_callback should stop queuing 🔍 bubbles
|
||||
and the drain loop should drop any already-queued events. Without this
|
||||
guard, the stop acknowledgement appears first but is followed by a trail
|
||||
of tool-progress bubbles for calls that were already parsed from the LLM
|
||||
response — making the interrupt feel ignored.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import BasePlatformAdapter, SendResult
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
class ProgressCaptureAdapter(BasePlatformAdapter):
|
||||
def __init__(self, platform=Platform.TELEGRAM):
|
||||
super().__init__(PlatformConfig(enabled=True, token="***"), platform)
|
||||
self.sent = []
|
||||
self.edits = []
|
||||
self.typing = []
|
||||
|
||||
async def connect(self) -> bool:
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
return None
|
||||
|
||||
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
|
||||
self.sent.append({"chat_id": chat_id, "content": content})
|
||||
return SendResult(success=True, message_id="progress-1")
|
||||
|
||||
async def edit_message(self, chat_id, message_id, content) -> SendResult:
|
||||
self.edits.append({"message_id": message_id, "content": content})
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
|
||||
async def send_typing(self, chat_id, metadata=None) -> None:
|
||||
self.typing.append(chat_id)
|
||||
|
||||
async def stop_typing(self, chat_id) -> None:
|
||||
return None
|
||||
|
||||
async def get_chat_info(self, chat_id: str):
|
||||
return {"id": chat_id}
|
||||
|
||||
|
||||
class PreInterruptAgent:
|
||||
"""Fires tool-progress events BEFORE the interrupt lands.
|
||||
|
||||
These should render normally. Baseline for comparison with the
|
||||
interrupted case — proves the harness renders events when no
|
||||
interrupt is active.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
||||
self.tools = []
|
||||
self._interrupt_requested = False
|
||||
|
||||
@property
|
||||
def is_interrupted(self) -> bool:
|
||||
return self._interrupt_requested
|
||||
|
||||
def run_conversation(self, message, conversation_history=None, task_id=None):
|
||||
self.tool_progress_callback("tool.started", "web_search", "first search", {})
|
||||
time.sleep(0.35) # let the drain loop process
|
||||
return {"final_response": "done", "messages": [], "api_calls": 1}
|
||||
|
||||
|
||||
class InterruptedAgent:
|
||||
"""Fires tool.started events AFTER interrupt — all should be suppressed.
|
||||
|
||||
Mirrors the failure mode in the bug report: LLM returned N parallel
|
||||
web_search calls, interrupt flag flipped, remaining events still
|
||||
rendered as bubbles. With the fix, none of these should appear.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
||||
self.tools = []
|
||||
# Start already interrupted — simulates stop having already landed
|
||||
# by the time the agent batch starts firing tool.started events.
|
||||
self._interrupt_requested = True
|
||||
|
||||
@property
|
||||
def is_interrupted(self) -> bool:
|
||||
return self._interrupt_requested
|
||||
|
||||
def run_conversation(self, message, conversation_history=None, task_id=None):
|
||||
# Parallel tool batch — in production these come from one LLM
|
||||
# response with 5 tool_calls. All are post-interrupt.
|
||||
self.tool_progress_callback("tool.started", "web_search", "cognee hermes", {})
|
||||
self.tool_progress_callback("tool.started", "web_search", "McBee deer hunting", {})
|
||||
self.tool_progress_callback("tool.started", "web_search", "kuzu graph db", {})
|
||||
self.tool_progress_callback("tool.started", "web_search", "moonshot kimi api", {})
|
||||
self.tool_progress_callback("tool.started", "web_search", "platform.moonshot.cn", {})
|
||||
time.sleep(0.35) # let the drain loop attempt to process the queue
|
||||
return {"final_response": "interrupted", "messages": [], "api_calls": 1}
|
||||
|
||||
|
||||
def _make_runner(adapter):
|
||||
gateway_run = importlib.import_module("gateway.run")
|
||||
GatewayRunner = gateway_run.GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.adapters = {adapter.platform: adapter}
|
||||
runner._voice_mode = {}
|
||||
runner._prefill_messages = []
|
||||
runner._ephemeral_system_prompt = ""
|
||||
runner._reasoning_config = None
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._session_db = None
|
||||
runner._running_agents = {}
|
||||
runner._session_run_generation = {}
|
||||
runner.hooks = SimpleNamespace(loaded_hooks=False)
|
||||
runner.config = SimpleNamespace(
|
||||
thread_sessions_per_user=False,
|
||||
group_sessions_per_user=False,
|
||||
stt_enabled=False,
|
||||
)
|
||||
return runner
|
||||
|
||||
|
||||
async def _run_once(monkeypatch, tmp_path, agent_cls, session_id):
|
||||
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
||||
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||||
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = agent_cls
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
|
||||
adapter = ProgressCaptureAdapter()
|
||||
runner = _make_runner(adapter)
|
||||
gateway_run = importlib.import_module("gateway.run")
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(
|
||||
gateway_run,
|
||||
"_resolve_runtime_agent_kwargs",
|
||||
lambda: {"api_key": "fake"},
|
||||
)
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1001",
|
||||
chat_type="group",
|
||||
thread_id="17585",
|
||||
)
|
||||
result = await runner._run_agent(
|
||||
message="hi",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=source,
|
||||
session_id=session_id,
|
||||
session_key="agent:main:telegram:group:-1001:17585",
|
||||
)
|
||||
return adapter, result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_baseline_non_interrupted_agent_renders_progress(monkeypatch, tmp_path):
|
||||
"""Sanity check: when is_interrupted is False, tool-progress renders normally."""
|
||||
adapter, result = await _run_once(monkeypatch, tmp_path, PreInterruptAgent, "sess-baseline")
|
||||
assert result["final_response"] == "done"
|
||||
rendered = " ".join(c["content"] for c in adapter.sent) + " " + " ".join(
|
||||
c["content"] for c in adapter.edits
|
||||
)
|
||||
assert "first search" in rendered, (
|
||||
"baseline agent should render its tool-progress event — "
|
||||
"if this fails the test harness is broken, not the fix"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_progress_suppressed_when_agent_is_interrupted(monkeypatch, tmp_path):
|
||||
"""Post-interrupt tool.started events must not render as bubbles.
|
||||
|
||||
This is Bug B from the screenshot: user sends `stop`, agent acks with
|
||||
⚡ Interrupting, but 5 more 🔍 web_search bubbles still render because
|
||||
their tool.started events were already parsed from the LLM response.
|
||||
With the fix, progress_callback and the drain loop both check
|
||||
is_interrupted and skip these events.
|
||||
"""
|
||||
adapter, result = await _run_once(
|
||||
monkeypatch, tmp_path, InterruptedAgent, "sess-interrupted"
|
||||
)
|
||||
assert result["final_response"] == "interrupted"
|
||||
|
||||
rendered = " ".join(c["content"] for c in adapter.sent) + " " + " ".join(
|
||||
c["content"] for c in adapter.edits
|
||||
)
|
||||
|
||||
# None of the post-interrupt queries should appear.
|
||||
for leaked_query in (
|
||||
"cognee hermes",
|
||||
"McBee deer hunting",
|
||||
"kuzu graph db",
|
||||
"moonshot kimi api",
|
||||
"platform.moonshot.cn",
|
||||
):
|
||||
assert leaked_query not in rendered, (
|
||||
f"event '{leaked_query}' leaked into the UI after interrupt — "
|
||||
f"progress_callback / drain loop is not checking is_interrupted"
|
||||
)
|
||||
@@ -165,3 +165,26 @@ async def test_reasoning_rejected_mid_run():
|
||||
assert result is not None
|
||||
assert "can't run mid-turn" in result
|
||||
assert "/reasoning" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_btw_dispatches_mid_run():
|
||||
"""/btw mid-run must dispatch to /background's handler, not hit the catch-all.
|
||||
|
||||
/btw is an alias of /background (see hermes_cli/commands.py). Typing
|
||||
/btw mid-turn must spawn a parallel background task — that's the whole
|
||||
point of the command. Before the mid-turn bypass was added for
|
||||
/background, /btw fell through to the "Agent is running — wait or
|
||||
/stop first" catch-all, making it useless in exactly the scenario it
|
||||
was designed for. The alias and the bypass together make it work.
|
||||
"""
|
||||
runner = _make_runner()
|
||||
runner._handle_background_command = AsyncMock(
|
||||
return_value='🚀 Background task started: "what module owns titles?"'
|
||||
)
|
||||
|
||||
result = await runner._handle_message(_make_event("/btw what module owns titles?"))
|
||||
|
||||
runner._handle_background_command.assert_awaited_once()
|
||||
assert result is not None
|
||||
assert "can't run mid-turn" not in result
|
||||
|
||||
@@ -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"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user