Compare commits

..

9 Commits

Author SHA1 Message Date
Mariano Nicolini c45d18265c fix tests 2026-04-10 18:59:35 -03:00
Mariano Nicolini 1c6d144a10 Merge branch 'main' into api-server-enforce-key 2026-04-10 18:47:59 -03:00
Mariano Nicolini 2b4abf8d9c move is_network_accessible helper to base.py 2026-04-10 18:36:19 -03:00
Mariano Nicolini f8dbe0ffd1 Merge branch 'main' into api-server-enforce-key 2026-04-10 11:14:20 -03:00
Mariano Nicolini 42e7755d4c Merge branch 'main' into api-server-enforce-key 2026-04-09 21:12:02 -03:00
Mariano Nicolini 68954b7c03 add helper function to check if host is network accessible and add tests for that function 2026-04-09 21:10:24 -03:00
Mariano Nicolini 95220facdf Merge branch 'main' into api-server-enforce-key 2026-04-09 17:20:30 -03:00
Mariano Nicolini 5ea9bf70de update code comments and documentation 2026-04-09 14:59:44 -03:00
Mariano Nicolini 67e4d43ea1 enforce api key when interface is not loopback 2026-04-09 14:29:44 -03:00
148 changed files with 3200 additions and 11461 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ ENV PYTHONUNBUFFERED=1
# Install system dependencies in one layer, clear APT cache
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential nodejs npm python3 python3-pip ripgrep ffmpeg gcc python3-dev libffi-dev procps && \
build-essential nodejs npm python3 python3-pip ripgrep ffmpeg gcc python3-dev libffi-dev && \
rm -rf /var/lib/apt/lists/*
COPY . /opt/hermes
+8 -20
View File
@@ -60,8 +60,6 @@ _ANTHROPIC_OUTPUT_LIMITS = {
"claude-3-opus": 4_096,
"claude-3-sonnet": 4_096,
"claude-3-haiku": 4_096,
# Third-party Anthropic-compatible providers
"minimax": 131_072,
}
# For any model not in the table, assume the highest current limit.
@@ -163,27 +161,18 @@ def _get_claude_code_version() -> str:
def _is_oauth_token(key: str) -> bool:
"""Check if the key is an Anthropic OAuth/setup token.
"""Check if the key is an OAuth/setup token (not a regular Console API key).
Positively identifies Anthropic OAuth tokens by their key format:
- ``sk-ant-`` prefix (but NOT ``sk-ant-api``) → setup tokens, managed keys
- ``eyJ`` prefix → JWTs from the Anthropic OAuth flow
Non-Anthropic keys (MiniMax, Alibaba, etc.) don't match either pattern
and correctly return False.
Regular API keys start with 'sk-ant-api'. Everything else (setup-tokens
starting with 'sk-ant-oat', managed keys, JWTs, etc.) needs Bearer auth.
"""
if not key:
return False
# Regular Anthropic Console API keys x-api-key auth, never OAuth
# Regular Console API keys use x-api-key header
if key.startswith("sk-ant-api"):
return False
# Anthropic-issued tokens (setup-tokens sk-ant-oat-*, managed keys)
if key.startswith("sk-ant-"):
return True
# JWTs from Anthropic OAuth flow
if key.startswith("eyJ"):
return True
return False
# Everything else (setup-tokens, managed keys, JWTs) uses Bearer auth
return True
def _normalize_base_url_text(base_url) -> str:
@@ -1315,10 +1304,9 @@ def build_anthropic_kwargs(
# Map reasoning_config to Anthropic's thinking parameter.
# Claude 4.6 models use adaptive thinking + output_config.effort.
# Older models use manual thinking with budget_tokens.
# MiniMax Anthropic-compat endpoints support thinking (manual mode only,
# not adaptive). Haiku does NOT support extended thinking — skip entirely.
# Haiku and MiniMax models do NOT support extended thinking — skip entirely.
if reasoning_config and isinstance(reasoning_config, dict):
if reasoning_config.get("enabled") is not False and "haiku" not in model.lower():
if reasoning_config.get("enabled") is not False and "haiku" not in model.lower() and "minimax" not in model.lower():
effort = str(reasoning_config.get("effort", "medium")).lower()
budget = THINKING_BUDGET.get(effort, 8000)
if _supports_adaptive_thinking(model):
+52 -265
View File
@@ -59,9 +59,6 @@ from hermes_constants import OPENROUTER_BASE_URL
logger = logging.getLogger(__name__)
# Module-level flag: only warn once per process about stale OPENAI_BASE_URL.
_stale_base_url_warned = False
_PROVIDER_ALIASES = {
"google": "gemini",
"google-gemini": "gemini",
@@ -710,9 +707,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
base_url = _to_openai_base_url(
_pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url
)
model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id)
if model is None:
continue # skip provider if we don't know a valid aux model
model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, "default")
logger.debug("Auxiliary text client: %s (%s) via pool", pconfig.name, model)
extra = {}
if "api.kimi.com" in base_url.lower():
@@ -731,9 +726,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
base_url = _to_openai_base_url(
str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
)
model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id)
if model is None:
continue # skip provider if we don't know a valid aux model
model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, "default")
logger.debug("Auxiliary text client: %s (%s)", pconfig.name, model)
extra = {}
if "api.kimi.com" in base_url.lower():
@@ -864,7 +857,7 @@ def _read_main_provider() -> str:
return ""
def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str], Optional[str]]:
def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str]]:
"""Resolve the active custom/main endpoint the same way the main CLI does.
This covers both env-driven OPENAI_BASE_URL setups and config-saved custom
@@ -877,29 +870,18 @@ def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str], Optional[st
runtime = resolve_runtime_provider(requested="custom")
except Exception as exc:
logger.debug("Auxiliary client: custom runtime resolution failed: %s", exc)
runtime = None
if not isinstance(runtime, dict):
openai_base = os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/")
openai_key = os.getenv("OPENAI_API_KEY", "").strip()
if not openai_base:
return None, None, None
runtime = {
"base_url": openai_base,
"api_key": openai_key,
}
return None, None
custom_base = runtime.get("base_url")
custom_key = runtime.get("api_key")
custom_mode = runtime.get("api_mode")
if not isinstance(custom_base, str) or not custom_base.strip():
return None, None, None
return None, None
custom_base = custom_base.strip().rstrip("/")
if "openrouter.ai" in custom_base.lower():
# requested='custom' falls back to OpenRouter when no custom endpoint is
# configured. Treat that as "no custom endpoint" for auxiliary routing.
return None, None, None
return None, None
# Local servers (Ollama, llama.cpp, vLLM, LM Studio) don't require auth.
# Use a placeholder key — the OpenAI SDK requires a non-empty string but
@@ -908,33 +890,20 @@ def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str], Optional[st
if not isinstance(custom_key, str) or not custom_key.strip():
custom_key = "no-key-required"
if not isinstance(custom_mode, str) or not custom_mode.strip():
custom_mode = None
return custom_base, custom_key.strip(), custom_mode
return custom_base, custom_key.strip()
def _current_custom_base_url() -> str:
custom_base, _, _ = _resolve_custom_runtime()
custom_base, _ = _resolve_custom_runtime()
return custom_base or ""
def _try_custom_endpoint() -> Tuple[Optional[OpenAI], Optional[str]]:
runtime = _resolve_custom_runtime()
if len(runtime) == 2:
custom_base, custom_key = runtime
custom_mode = None
else:
custom_base, custom_key, custom_mode = runtime
custom_base, custom_key = _resolve_custom_runtime()
if not custom_base or not custom_key:
return None, None
if custom_base.lower().startswith(_CODEX_AUX_BASE_URL.lower()):
return None, None
model = _read_main_model() or "gpt-4o-mini"
logger.debug("Auxiliary client: custom endpoint (%s, api_mode=%s)", model, custom_mode or "chat_completions")
if custom_mode == "codex_responses":
real_client = OpenAI(api_key=custom_key, base_url=custom_base)
return CodexAuxiliaryClient(real_client, model), model
logger.debug("Auxiliary client: custom endpoint (%s)", model)
return OpenAI(api_key=custom_key, base_url=custom_base), model
@@ -1082,12 +1051,11 @@ def _is_connection_error(exc: Exception) -> bool:
def _try_payment_fallback(
failed_provider: str,
task: str = None,
reason: str = "payment error",
) -> Tuple[Optional[Any], Optional[str], str]:
"""Try alternative providers after a payment/credit or connection error.
"""Try alternative providers after a payment/credit error.
Iterates the standard auto-detection chain, skipping the provider that
failed.
returned a payment error.
Returns:
(client, model, provider_label) or (None, None, "") if no fallback.
@@ -1113,15 +1081,15 @@ def _try_payment_fallback(
client, model = try_fn()
if client is not None:
logger.info(
"Auxiliary %s: %s on %s — falling back to %s (%s)",
task or "call", reason, failed_provider, label, model or "default",
"Auxiliary %s: payment error on %s — falling back to %s (%s)",
task or "call", failed_provider, label, model or "default",
)
return client, model, label
tried.append(label)
logger.warning(
"Auxiliary %s: %s on %s and no fallback available (tried: %s)",
task or "call", reason, failed_provider, ", ".join(tried),
"Auxiliary %s: payment error on %s and no fallback available (tried: %s)",
task or "call", failed_provider, ", ".join(tried),
)
return None, None, ""
@@ -1136,28 +1104,9 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:
provider they already have credentials for — no OpenRouter key needed.
2. OpenRouter → Nous → custom → Codex → API-key providers (original chain).
"""
global auxiliary_is_nous, _stale_base_url_warned
global auxiliary_is_nous
auxiliary_is_nous = False # Reset — _try_nous() will set True if it wins
# ── Warn once if OPENAI_BASE_URL is set but config.yaml uses a named
# provider (not 'custom'). This catches the common "env poisoning"
# scenario where a user switches providers via `hermes model` but the
# old OPENAI_BASE_URL lingers in ~/.hermes/.env. ──
if not _stale_base_url_warned:
_env_base = os.getenv("OPENAI_BASE_URL", "").strip()
_cfg_provider = _read_main_provider()
if (_env_base and _cfg_provider
and _cfg_provider != "custom"
and not _cfg_provider.startswith("custom:")):
logger.warning(
"OPENAI_BASE_URL is set (%s) but model.provider is '%s'. "
"Auxiliary clients may route to the wrong endpoint. "
"Run: hermes model to reconfigure, or remove "
"OPENAI_BASE_URL from ~/.hermes/.env",
_env_base, _cfg_provider,
)
_stale_base_url_warned = True
# ── Step 1: non-aggregator main provider → use main model directly ──
main_provider = _read_main_provider()
main_model = _read_main_model()
@@ -1244,7 +1193,6 @@ def resolve_provider_client(
raw_codex: bool = False,
explicit_base_url: str = None,
explicit_api_key: str = None,
api_mode: str = None,
) -> Tuple[Optional[Any], Optional[str]]:
"""Central router: given a provider name and optional model, return a
configured client with the correct auth, base URL, and API format.
@@ -1268,10 +1216,6 @@ def resolve_provider_client(
the main agent loop).
explicit_base_url: Optional direct OpenAI-compatible endpoint.
explicit_api_key: Optional API key paired with explicit_base_url.
api_mode: API mode override. One of "chat_completions",
"codex_responses", or None (auto-detect). When set to
"codex_responses", the client is wrapped in
CodexAuxiliaryClient to route through the Responses API.
Returns:
(client, resolved_model) or (None, None) if auth is unavailable.
@@ -1279,40 +1223,6 @@ def resolve_provider_client(
# Normalise aliases
provider = _normalize_aux_provider(provider)
def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool:
"""Decide if a plain OpenAI client should be wrapped for Responses API.
Returns True when api_mode is explicitly "codex_responses", or when
auto-detection (api.openai.com + codex-family model) suggests it.
Already-wrapped clients (CodexAuxiliaryClient) are skipped.
"""
if isinstance(client_obj, CodexAuxiliaryClient):
return False
if raw_codex:
return False
if api_mode == "codex_responses":
return True
# Auto-detect: api.openai.com + codex model name pattern
if api_mode and api_mode != "codex_responses":
return False # explicit non-codex mode
normalized_base = (base_url_str or "").strip().lower()
if "api.openai.com" in normalized_base and "openrouter" not in normalized_base:
model_lower = (model_str or "").lower()
if "codex" in model_lower:
return True
return False
def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):
"""Wrap a plain OpenAI client in CodexAuxiliaryClient if Responses API is needed."""
if _needs_codex_wrap(client_obj, base_url_str, final_model_str):
logger.debug(
"resolve_provider_client: wrapping client in CodexAuxiliaryClient "
"(api_mode=%s, model=%s, base_url=%s)",
api_mode or "auto-detected", final_model_str,
base_url_str[:60] if base_url_str else "")
return CodexAuxiliaryClient(client_obj, final_model_str)
return client_obj
# ── Auto: try all providers in priority order ────────────────────
if provider == "auto":
client, resolved = _resolve_auto()
@@ -1402,7 +1312,6 @@ def resolve_provider_client(
from hermes_cli.models import copilot_default_headers
extra["default_headers"] = copilot_default_headers()
client = OpenAI(api_key=custom_key, base_url=custom_base, **extra)
client = _wrap_if_needed(client, final_model, custom_base)
return (_to_async_client(client, final_model) if async_mode
else (client, final_model))
# Try custom first, then codex, then API-key providers
@@ -1411,8 +1320,6 @@ def resolve_provider_client(
client, default = try_fn()
if client is not None:
final_model = _normalize_resolved_model(model or default, provider)
_cbase = str(getattr(client, "base_url", "") or "")
client = _wrap_if_needed(client, final_model, _cbase)
return (_to_async_client(client, final_model) if async_mode
else (client, final_model))
logger.warning("resolve_provider_client: custom/main requested "
@@ -1432,7 +1339,6 @@ def resolve_provider_client(
provider,
)
client = OpenAI(api_key=custom_key, base_url=custom_base)
client = _wrap_if_needed(client, final_model, custom_base)
logger.debug(
"resolve_provider_client: named custom provider %r (%s)",
provider, final_model)
@@ -1495,28 +1401,6 @@ def resolve_provider_client(
client = OpenAI(api_key=api_key, base_url=base_url,
**({"default_headers": headers} if headers else {}))
# Copilot GPT-5+ models (except gpt-5-mini) require the Responses
# API — they are not accessible via /chat/completions. Wrap the
# plain client in CodexAuxiliaryClient so call_llm() transparently
# routes through responses.stream().
if provider == "copilot" and final_model and not raw_codex:
try:
from hermes_cli.models import _should_use_copilot_responses_api
if _should_use_copilot_responses_api(final_model):
logger.debug(
"resolve_provider_client: copilot model %s needs "
"Responses API — wrapping with CodexAuxiliaryClient",
final_model)
client = CodexAuxiliaryClient(client, final_model)
except ImportError:
pass
# Honor api_mode for any API-key provider (e.g. direct OpenAI with
# codex-family models). The copilot-specific wrapping above handles
# copilot; this covers the general case (#6800).
client = _wrap_if_needed(client, final_model, base_url)
logger.debug("resolve_provider_client: %s (%s)", provider, final_model)
return (_to_async_client(client, final_model) if async_mode
else (client, final_model))
@@ -1549,13 +1433,12 @@ def get_text_auxiliary_client(task: str = "") -> Tuple[Optional[OpenAI], Optiona
Callers may override the returned model with a per-task env var
(e.g. CONTEXT_COMPRESSION_MODEL, AUXILIARY_WEB_EXTRACT_MODEL).
"""
provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(task or None)
provider, model, base_url, api_key = _resolve_task_provider_model(task or None)
return resolve_provider_client(
provider,
model=model,
explicit_base_url=base_url,
explicit_api_key=api_key,
api_mode=api_mode,
)
@@ -1566,14 +1449,13 @@ def get_async_text_auxiliary_client(task: str = ""):
(AsyncCodexAuxiliaryClient, model) which wraps the Responses API.
Returns (None, None) when no provider is available.
"""
provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(task or None)
provider, model, base_url, api_key = _resolve_task_provider_model(task or None)
return resolve_provider_client(
provider,
model=model,
async_mode=True,
explicit_base_url=base_url,
explicit_api_key=api_key,
api_mode=api_mode,
)
@@ -1646,7 +1528,7 @@ def resolve_vision_provider_client(
backends, so users can intentionally force experimental providers. Auto mode
stays conservative and only tries vision backends known to work today.
"""
requested, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(
requested, resolved_model, resolved_base_url, resolved_api_key = _resolve_task_provider_model(
"vision", provider, model, base_url, api_key
)
requested = _normalize_vision_provider(requested)
@@ -1862,30 +1744,12 @@ def cleanup_stale_async_clients() -> None:
del _client_cache[key]
def _is_openrouter_client(client: Any) -> bool:
for obj in (client, getattr(client, "_client", None), getattr(client, "client", None)):
if obj and "openrouter" in str(getattr(obj, "base_url", "") or "").lower():
return True
return False
def _compat_model(client: Any, model: Optional[str], cached_default: Optional[str]) -> Optional[str]:
"""Drop OpenRouter-format model slugs (with '/') for non-OpenRouter clients.
Mirrors the guard in resolve_provider_client() which is skipped on cache hits.
"""
if model and "/" in model and not _is_openrouter_client(client):
return cached_default
return model or cached_default
def _get_cached_client(
provider: str,
model: str = None,
async_mode: bool = False,
base_url: str = None,
api_key: str = None,
api_mode: str = None,
) -> Tuple[Optional[Any], Optional[str]]:
"""Get or create a cached client for the given provider.
@@ -1909,7 +1773,7 @@ def _get_cached_client(
loop_id = id(current_loop)
except RuntimeError:
pass
cache_key = (provider, async_mode, base_url or "", api_key or "", api_mode or "", loop_id)
cache_key = (provider, async_mode, base_url or "", api_key or "", loop_id)
with _client_cache_lock:
if cache_key in _client_cache:
cached_client, cached_default, cached_loop = _client_cache[cache_key]
@@ -1921,11 +1785,9 @@ def _get_cached_client(
_force_close_async_httpx(cached_client)
del _client_cache[cache_key]
else:
effective = _compat_model(cached_client, model, cached_default)
return cached_client, effective
return cached_client, model or cached_default
else:
effective = _compat_model(cached_client, model, cached_default)
return cached_client, effective
return cached_client, model or cached_default
# Build outside the lock
client, default_model = resolve_provider_client(
provider,
@@ -1933,7 +1795,6 @@ def _get_cached_client(
async_mode,
explicit_base_url=base_url,
explicit_api_key=api_key,
api_mode=api_mode,
)
if client is not None:
# For async clients, remember which loop they were created on so we
@@ -1953,7 +1814,7 @@ def _resolve_task_provider_model(
model: str = None,
base_url: str = None,
api_key: str = None,
) -> Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]:
) -> Tuple[str, Optional[str], Optional[str], Optional[str]]:
"""Determine provider + model for a call.
Priority:
@@ -1962,17 +1823,15 @@ def _resolve_task_provider_model(
3. Config file (auxiliary.{task}.* or compression.*)
4. "auto" (full auto-detection chain)
Returns (provider, model, base_url, api_key, api_mode) where model may
be None (use provider default). When base_url is set, provider is forced
to "custom" and the task uses that direct endpoint. api_mode is one of
"chat_completions", "codex_responses", or None (auto-detect).
Returns (provider, model, base_url, api_key) where model may be None
(use provider default). When base_url is set, provider is forced to
"custom" and the task uses that direct endpoint.
"""
config = {}
cfg_provider = None
cfg_model = None
cfg_base_url = None
cfg_api_key = None
cfg_api_mode = None
if task:
try:
@@ -1989,7 +1848,6 @@ def _resolve_task_provider_model(
cfg_model = str(task_config.get("model", "")).strip() or None
cfg_base_url = str(task_config.get("base_url", "")).strip() or None
cfg_api_key = str(task_config.get("api_key", "")).strip() or None
cfg_api_mode = str(task_config.get("api_mode", "")).strip() or None
# Backwards compat: compression section has its own keys.
# The auxiliary.compression defaults to provider="auto", so treat
@@ -2003,32 +1861,30 @@ def _resolve_task_provider_model(
cfg_base_url = cfg_base_url or _sbu.strip() or None
env_model = _get_auxiliary_env_override(task, "MODEL") if task else None
env_api_mode = _get_auxiliary_env_override(task, "API_MODE") if task else None
resolved_model = model or env_model or cfg_model
resolved_api_mode = env_api_mode or cfg_api_mode
if base_url:
return "custom", resolved_model, base_url, api_key, resolved_api_mode
return "custom", resolved_model, base_url, api_key
if provider:
return provider, resolved_model, base_url, api_key, resolved_api_mode
return provider, resolved_model, base_url, api_key
if task:
env_base_url = _get_auxiliary_env_override(task, "BASE_URL")
env_api_key = _get_auxiliary_env_override(task, "API_KEY")
if env_base_url:
return "custom", resolved_model, env_base_url, env_api_key or cfg_api_key, resolved_api_mode
return "custom", resolved_model, env_base_url, env_api_key or cfg_api_key
env_provider = _get_auxiliary_provider(task)
if env_provider != "auto":
return env_provider, resolved_model, None, None, resolved_api_mode
return env_provider, resolved_model, None, None
if cfg_base_url:
return "custom", resolved_model, cfg_base_url, cfg_api_key, resolved_api_mode
return "custom", resolved_model, cfg_base_url, cfg_api_key
if cfg_provider and cfg_provider != "auto":
return cfg_provider, resolved_model, None, None, resolved_api_mode
return "auto", resolved_model, None, None, resolved_api_mode
return cfg_provider, resolved_model, None, None
return "auto", resolved_model, None, None
return "auto", resolved_model, None, None, resolved_api_mode
return "auto", resolved_model, None, None
_DEFAULT_AUX_TIMEOUT = 30.0
@@ -2100,37 +1956,6 @@ def _build_call_kwargs(
return kwargs
def _validate_llm_response(response: Any, task: str = None) -> Any:
"""Validate that an LLM response has the expected .choices[0].message shape.
Fails fast with a clear error instead of letting malformed payloads
propagate to downstream consumers where they crash with misleading
AttributeError (e.g. "'str' object has no attribute 'choices'").
See #7264.
"""
if response is None:
raise RuntimeError(
f"Auxiliary {task or 'call'}: LLM returned None response"
)
# Allow SimpleNamespace responses from adapters (CodexAuxiliaryClient,
# AnthropicAuxiliaryClient) — they have .choices[0].message.
try:
choices = response.choices
if not choices or not hasattr(choices[0], "message"):
raise AttributeError("missing choices[0].message")
except (AttributeError, TypeError, IndexError) as exc:
response_type = type(response).__name__
response_preview = str(response)[:120]
raise RuntimeError(
f"Auxiliary {task or 'call'}: LLM returned invalid response "
f"(type={response_type}): {response_preview!r}. "
f"Expected object with .choices[0].message — check provider "
f"adapter or custom endpoint compatibility."
) from exc
return response
def call_llm(
task: str = None,
*,
@@ -2169,7 +1994,7 @@ def call_llm(
Raises:
RuntimeError: If no provider is configured.
"""
resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(
resolved_provider, resolved_model, resolved_base_url, resolved_api_key = _resolve_task_provider_model(
task, provider, model, base_url, api_key)
if task == "vision":
@@ -2202,7 +2027,6 @@ def call_llm(
resolved_model,
base_url=resolved_base_url,
api_key=resolved_api_key,
api_mode=resolved_api_mode,
)
if client is None:
# When the user explicitly chose a non-OpenRouter provider but no
@@ -2246,20 +2070,18 @@ def call_llm(
# Handle max_tokens vs max_completion_tokens retry, then payment fallback.
try:
return _validate_llm_response(
client.chat.completions.create(**kwargs), task)
return client.chat.completions.create(**kwargs)
except Exception as first_err:
err_str = str(first_err)
if "max_tokens" in err_str or "unsupported_parameter" in err_str:
kwargs.pop("max_tokens", None)
kwargs["max_completion_tokens"] = max_tokens
try:
return _validate_llm_response(
client.chat.completions.create(**kwargs), task)
return client.chat.completions.create(**kwargs)
except Exception as retry_err:
# If the max_tokens retry also hits a payment or connection
# error, fall through to the fallback chain below.
if not (_is_payment_error(retry_err) or _is_connection_error(retry_err)):
# If the max_tokens retry also hits a payment error,
# fall through to the payment fallback below.
if not _is_payment_error(retry_err):
raise
first_err = retry_err
@@ -2276,24 +2098,19 @@ def call_llm(
# and providers the user never configured that got picked up by
# the auto-detection chain.
should_fallback = _is_payment_error(first_err) or _is_connection_error(first_err)
# Only try alternative providers when the user didn't explicitly
# configure this task's provider. Explicit provider = hard constraint;
# auto (the default) = best-effort fallback chain. (#7559)
is_auto = resolved_provider in ("auto", "", None)
if should_fallback and is_auto:
if should_fallback:
reason = "payment error" if _is_payment_error(first_err) else "connection error"
logger.info("Auxiliary %s: %s on %s (%s), trying fallback",
task or "call", reason, resolved_provider, first_err)
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason)
resolved_provider, task)
if fb_client is not None:
fb_kwargs = _build_call_kwargs(
fb_label, fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=extra_body)
return _validate_llm_response(
fb_client.chat.completions.create(**fb_kwargs), task)
return fb_client.chat.completions.create(**fb_kwargs)
raise
@@ -2371,7 +2188,7 @@ async def async_call_llm(
Same as call_llm() but async. See call_llm() for full documentation.
"""
resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(
resolved_provider, resolved_model, resolved_base_url, resolved_api_key = _resolve_task_provider_model(
task, provider, model, base_url, api_key)
if task == "vision":
@@ -2405,7 +2222,6 @@ async def async_call_llm(
async_mode=True,
base_url=resolved_base_url,
api_key=resolved_api_key,
api_mode=resolved_api_mode,
)
if client is None:
_explicit = (resolved_provider or "").strip().lower()
@@ -2416,9 +2232,11 @@ async def async_call_llm(
f"variable, or switch to a different provider with `hermes model`."
)
if not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider)
client, final_model = _get_cached_client("auto", async_mode=True)
logger.warning("Provider %s unavailable, falling back to openrouter",
resolved_provider)
client, final_model = _get_cached_client(
"openrouter", resolved_model or _OPENROUTER_MODEL,
async_mode=True)
if client is None:
raise RuntimeError(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
@@ -2433,42 +2251,11 @@ async def async_call_llm(
base_url=resolved_base_url)
try:
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task)
return await client.chat.completions.create(**kwargs)
except Exception as first_err:
err_str = str(first_err)
if "max_tokens" in err_str or "unsupported_parameter" in err_str:
kwargs.pop("max_tokens", None)
kwargs["max_completion_tokens"] = max_tokens
try:
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task)
except Exception as retry_err:
# If the max_tokens retry also hits a payment or connection
# error, fall through to the fallback chain below.
if not (_is_payment_error(retry_err) or _is_connection_error(retry_err)):
raise
first_err = retry_err
# ── Payment / connection fallback (mirrors sync call_llm) ─────
should_fallback = _is_payment_error(first_err) or _is_connection_error(first_err)
is_auto = resolved_provider in ("auto", "", None)
if should_fallback and is_auto:
reason = "payment error" if _is_payment_error(first_err) else "connection error"
logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback",
task or "call", reason, resolved_provider, first_err)
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason)
if fb_client is not None:
fb_kwargs = _build_call_kwargs(
fb_label, fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=extra_body)
# Convert sync fallback client to async
async_fb, async_fb_model = _to_async_client(fb_client, fb_model or "")
if async_fb_model and async_fb_model != fb_kwargs.get("model"):
fb_kwargs["model"] = async_fb_model
return _validate_llm_response(
await async_fb.chat.completions.create(**fb_kwargs), task)
return await client.chat.completions.create(**kwargs)
raise
+2 -30
View File
@@ -18,7 +18,6 @@ import time
from typing import Any, Dict, List, Optional
from agent.auxiliary_client import call_llm
from agent.context_engine import ContextEngine
from agent.model_metadata import (
get_model_context_length,
estimate_messages_tokens_rough,
@@ -51,8 +50,8 @@ _CHARS_PER_TOKEN = 4
_SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
class ContextCompressor(ContextEngine):
"""Default context engine — compresses conversation context via lossy summarization.
class ContextCompressor:
"""Compresses conversation context when approaching the model's context limit.
Algorithm:
1. Prune old tool results (cheap, no LLM call)
@@ -62,33 +61,6 @@ class ContextCompressor(ContextEngine):
5. On subsequent compactions, iteratively update the previous summary
"""
@property
def name(self) -> str:
return "compressor"
def on_session_reset(self) -> None:
"""Reset all per-session state for /new or /reset."""
super().on_session_reset()
self._context_probed = False
self._context_probe_persistable = False
self._previous_summary = None
def update_model(
self,
model: str,
context_length: int,
base_url: str = "",
api_key: str = "",
provider: str = "",
) -> None:
"""Update model info after a model switch or fallback activation."""
self.model = model
self.base_url = base_url
self.api_key = api_key
self.provider = provider
self.context_length = context_length
self.threshold_tokens = int(context_length * self.threshold_percent)
def __init__(
self,
model: str,
-184
View File
@@ -1,184 +0,0 @@
"""Abstract base class for pluggable context engines.
A context engine controls how conversation context is managed when
approaching the model's token limit. The built-in ContextCompressor
is the default implementation. Third-party engines (e.g. LCM) can
replace it via the plugin system or by being placed in the
``plugins/context_engine/<name>/`` directory.
Selection is config-driven: ``context.engine`` in config.yaml.
Default is ``"compressor"`` (the built-in). Only one engine is active.
The engine is responsible for:
- Deciding when compaction should fire
- Performing compaction (summarization, DAG construction, etc.)
- Optionally exposing tools the agent can call (e.g. lcm_grep)
- Tracking token usage from API responses
Lifecycle:
1. Engine is instantiated and registered (plugin register() or default)
2. on_session_start() called when a conversation begins
3. update_from_response() called after each API response with usage data
4. should_compress() checked after each turn
5. compress() called when should_compress() returns True
6. on_session_end() called at real session boundaries (CLI exit, /reset,
gateway session expiry) — NOT per-turn
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
class ContextEngine(ABC):
"""Base class all context engines must implement."""
# -- Identity ----------------------------------------------------------
@property
@abstractmethod
def name(self) -> str:
"""Short identifier (e.g. 'compressor', 'lcm')."""
# -- Token state (read by run_agent.py for display/logging) ------------
#
# Engines MUST maintain these. run_agent.py reads them directly.
last_prompt_tokens: int = 0
last_completion_tokens: int = 0
last_total_tokens: int = 0
threshold_tokens: int = 0
context_length: int = 0
compression_count: int = 0
# -- Compaction parameters (read by run_agent.py for preflight) --------
#
# These control the preflight compression check. Subclasses may
# override via __init__ or property; defaults are sensible for most
# engines.
threshold_percent: float = 0.75
protect_first_n: int = 3
protect_last_n: int = 6
# -- Core interface ----------------------------------------------------
@abstractmethod
def update_from_response(self, usage: Dict[str, Any]) -> None:
"""Update tracked token usage from an API response.
Called after every LLM call with the usage dict from the response.
"""
@abstractmethod
def should_compress(self, prompt_tokens: int = None) -> bool:
"""Return True if compaction should fire this turn."""
@abstractmethod
def compress(
self,
messages: List[Dict[str, Any]],
current_tokens: int = None,
) -> List[Dict[str, Any]]:
"""Compact the message list and return the new message list.
This is the main entry point. The engine receives the full message
list and returns a (possibly shorter) list that fits within the
context budget. The implementation is free to summarize, build a
DAG, or do anything else — as long as the returned list is a valid
OpenAI-format message sequence.
"""
# -- Optional: pre-flight check ----------------------------------------
def should_compress_preflight(self, messages: List[Dict[str, Any]]) -> bool:
"""Quick rough check before the API call (no real token count yet).
Default returns False (skip pre-flight). Override if your engine
can do a cheap estimate.
"""
return False
# -- Optional: session lifecycle ---------------------------------------
def on_session_start(self, session_id: str, **kwargs) -> None:
"""Called when a new conversation session begins.
Use this to load persisted state (DAG, store) for the session.
kwargs may include hermes_home, platform, model, etc.
"""
def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
"""Called at real session boundaries (CLI exit, /reset, gateway expiry).
Use this to flush state, close DB connections, etc.
NOT called per-turn — only when the session truly ends.
"""
def on_session_reset(self) -> None:
"""Called on /new or /reset. Reset per-session state.
Default resets compression_count and token tracking.
"""
self.last_prompt_tokens = 0
self.last_completion_tokens = 0
self.last_total_tokens = 0
self.compression_count = 0
# -- Optional: tools ---------------------------------------------------
def get_tool_schemas(self) -> List[Dict[str, Any]]:
"""Return tool schemas this engine provides to the agent.
Default returns empty list (no tools). LCM would return schemas
for lcm_grep, lcm_describe, lcm_expand here.
"""
return []
def handle_tool_call(self, name: str, args: Dict[str, Any], **kwargs) -> str:
"""Handle a tool call from the agent.
Only called for tool names returned by get_tool_schemas().
Must return a JSON string.
kwargs may include:
messages: the current in-memory message list (for live ingestion)
"""
import json
return json.dumps({"error": f"Unknown context engine tool: {name}"})
# -- Optional: status / display ----------------------------------------
def get_status(self) -> Dict[str, Any]:
"""Return status dict for display/logging.
Default returns the standard fields run_agent.py expects.
"""
return {
"last_prompt_tokens": self.last_prompt_tokens,
"threshold_tokens": self.threshold_tokens,
"context_length": self.context_length,
"usage_percent": (
min(100, self.last_prompt_tokens / self.context_length * 100)
if self.context_length else 0
),
"compression_count": self.compression_count,
}
# -- Optional: model switch support ------------------------------------
def update_model(
self,
model: str,
context_length: int,
base_url: str = "",
api_key: str = "",
provider: str = "",
) -> None:
"""Called when the user switches models or on fallback activation.
Default updates context_length and recalculates threshold_tokens
from threshold_percent. Override if your engine needs more
(e.g. recalculate DAG budgets, switch summary models).
"""
self.context_length = context_length
self.threshold_tokens = int(context_length * self.threshold_percent)
+11 -73
View File
@@ -21,73 +21,11 @@ _RESET = "\033[0m"
logger = logging.getLogger(__name__)
_ANSI_RESET = "\033[0m"
# Diff colors — resolved lazily from the skin engine so they adapt
# to light/dark themes. Falls back to sensible defaults on import
# failure. We cache after first resolution for performance.
_diff_colors_cached: dict[str, str] | None = None
def _diff_ansi() -> dict[str, str]:
"""Return ANSI escapes for diff display, resolved from the active skin."""
global _diff_colors_cached
if _diff_colors_cached is not None:
return _diff_colors_cached
# Defaults that work on dark terminals
dim = "\033[38;2;150;150;150m"
file_c = "\033[38;2;180;160;255m"
hunk = "\033[38;2;120;120;140m"
minus = "\033[38;2;255;255;255;48;2;120;20;20m"
plus = "\033[38;2;255;255;255;48;2;20;90;20m"
try:
from hermes_cli.skin_engine import get_active_skin
skin = get_active_skin()
def _hex_fg(key: str, fallback_rgb: tuple[int, int, int]) -> str:
h = skin.get_color(key, "")
if h and len(h) == 7 and h[0] == "#":
r, g, b = int(h[1:3], 16), int(h[3:5], 16), int(h[5:7], 16)
return f"\033[38;2;{r};{g};{b}m"
r, g, b = fallback_rgb
return f"\033[38;2;{r};{g};{b}m"
dim = _hex_fg("banner_dim", (150, 150, 150))
file_c = _hex_fg("session_label", (180, 160, 255))
hunk = _hex_fg("session_border", (120, 120, 140))
# minus/plus use background colors — derive from ui_error/ui_ok
err_h = skin.get_color("ui_error", "#ef5350")
ok_h = skin.get_color("ui_ok", "#4caf50")
if err_h and len(err_h) == 7:
er, eg, eb = int(err_h[1:3], 16), int(err_h[3:5], 16), int(err_h[5:7], 16)
# Use a dark tinted version as background
minus = f"\033[38;2;255;255;255;48;2;{max(er//2,20)};{max(eg//4,10)};{max(eb//4,10)}m"
if ok_h and len(ok_h) == 7:
or_, og, ob = int(ok_h[1:3], 16), int(ok_h[3:5], 16), int(ok_h[5:7], 16)
plus = f"\033[38;2;255;255;255;48;2;{max(or_//4,10)};{max(og//2,20)};{max(ob//4,10)}m"
except Exception:
pass
_diff_colors_cached = {
"dim": dim, "file": file_c, "hunk": hunk,
"minus": minus, "plus": plus,
}
return _diff_colors_cached
def reset_diff_colors() -> None:
"""Reset cached diff colors (call after /skin switch)."""
global _diff_colors_cached
_diff_colors_cached = None
# Module-level helpers — each call resolves from the active skin lazily.
def _diff_dim(): return _diff_ansi()["dim"]
def _diff_file(): return _diff_ansi()["file"]
def _diff_hunk(): return _diff_ansi()["hunk"]
def _diff_minus(): return _diff_ansi()["minus"]
def _diff_plus(): return _diff_ansi()["plus"]
_ANSI_DIM = "\033[38;2;150;150;150m"
_ANSI_FILE = "\033[38;2;180;160;255m"
_ANSI_HUNK = "\033[38;2;120;120;140m"
_ANSI_MINUS = "\033[38;2;255;255;255;48;2;120;20;20m"
_ANSI_PLUS = "\033[38;2;255;255;255;48;2;20;90;20m"
_MAX_INLINE_DIFF_FILES = 6
_MAX_INLINE_DIFF_LINES = 80
@@ -465,19 +403,19 @@ def _render_inline_unified_diff(diff: str) -> list[str]:
if raw_line.startswith("+++ "):
to_file = raw_line[4:].strip()
if from_file or to_file:
rendered.append(f"{_diff_file()}{from_file or 'a/?'}{to_file or 'b/?'}{_ANSI_RESET}")
rendered.append(f"{_ANSI_FILE}{from_file or 'a/?'}{to_file or 'b/?'}{_ANSI_RESET}")
continue
if raw_line.startswith("@@"):
rendered.append(f"{_diff_hunk()}{raw_line}{_ANSI_RESET}")
rendered.append(f"{_ANSI_HUNK}{raw_line}{_ANSI_RESET}")
continue
if raw_line.startswith("-"):
rendered.append(f"{_diff_minus()}{raw_line}{_ANSI_RESET}")
rendered.append(f"{_ANSI_MINUS}{raw_line}{_ANSI_RESET}")
continue
if raw_line.startswith("+"):
rendered.append(f"{_diff_plus()}{raw_line}{_ANSI_RESET}")
rendered.append(f"{_ANSI_PLUS}{raw_line}{_ANSI_RESET}")
continue
if raw_line.startswith(" "):
rendered.append(f"{_diff_dim()}{raw_line}{_ANSI_RESET}")
rendered.append(f"{_ANSI_DIM}{raw_line}{_ANSI_RESET}")
continue
if raw_line:
rendered.append(raw_line)
@@ -543,7 +481,7 @@ def _summarize_rendered_diff_sections(
summary = f"… omitted {omitted_lines} diff line(s)"
if omitted_files:
summary += f" across {omitted_files} additional file(s)/section(s)"
rendered.append(f"{_diff_hunk()}{summary}{_ANSI_RESET}")
rendered.append(f"{_ANSI_HUNK}{summary}{_ANSI_RESET}")
return rendered
-49
View File
@@ -1,49 +0,0 @@
"""User-facing summaries for manual compression commands."""
from __future__ import annotations
from typing import Any, Sequence
def summarize_manual_compression(
before_messages: Sequence[dict[str, Any]],
after_messages: Sequence[dict[str, Any]],
before_tokens: int,
after_tokens: int,
) -> dict[str, Any]:
"""Return consistent user-facing feedback for manual compression."""
before_count = len(before_messages)
after_count = len(after_messages)
noop = list(after_messages) == list(before_messages)
if noop:
headline = f"No changes from compression: {before_count} messages"
if after_tokens == before_tokens:
token_line = (
f"Rough transcript estimate: ~{before_tokens:,} tokens (unchanged)"
)
else:
token_line = (
f"Rough transcript estimate: ~{before_tokens:,}"
f"~{after_tokens:,} tokens"
)
else:
headline = f"Compressed: {before_count}{after_count} messages"
token_line = (
f"Rough transcript estimate: ~{before_tokens:,}"
f"~{after_tokens:,} tokens"
)
note = None
if not noop and after_count < before_count and after_tokens > before_tokens:
note = (
"Note: fewer messages can still raise this rough transcript estimate "
"when compression rewrites the transcript into denser summaries."
)
return {
"noop": noop,
"headline": headline,
"token_line": token_line,
"note": note,
}
+11 -8
View File
@@ -113,14 +113,17 @@ DEFAULT_CONTEXT_LENGTHS = {
"deepseek": 128000,
# Meta
"llama": 131072,
# Qwen — specific model families before the catch-all.
# Official docs: https://help.aliyun.com/zh/model-studio/developer-reference/
"qwen3-coder-plus": 1000000, # 1M context
"qwen3-coder": 262144, # 256K context
# Qwen
"qwen": 131072,
# MiniMax — official docs: 204,800 context for all models
# https://platform.minimax.io/docs/api-reference/text-anthropic-api
"minimax": 204800,
# MiniMax (lowercase — lookup lowercases model names at line 973)
"minimax-m1-256k": 1000000,
"minimax-m1-128k": 1000000,
"minimax-m1-80k": 1000000,
"minimax-m1-40k": 1000000,
"minimax-m1": 1000000,
"minimax-m2.5": 1048576,
"minimax-m2.7": 1048576,
"minimax": 1048576,
# GLM
"glm": 202752,
# xAI Grok — xAI /v1/models does not return context_length metadata,
@@ -148,7 +151,7 @@ DEFAULT_CONTEXT_LENGTHS = {
"deepseek-ai/DeepSeek-V3.2": 65536,
"moonshotai/Kimi-K2.5": 262144,
"moonshotai/Kimi-K2-Thinking": 262144,
"MiniMaxAI/MiniMax-M2.5": 204800,
"MiniMaxAI/MiniMax-M2.5": 1048576,
"XiaomiMiMo/MiMo-V2-Flash": 32768,
"mimo-v2-pro": 1048576,
"mimo-v2-omni": 1048576,
+3 -4
View File
@@ -487,7 +487,7 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:
(True, {}, "") to err on the side of showing the skill.
"""
try:
raw = skill_file.read_text(encoding="utf-8")
raw = skill_file.read_text(encoding="utf-8")[:2000]
frontmatter, _ = parse_frontmatter(raw)
if not skill_matches_platform(frontmatter):
@@ -495,7 +495,7 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:
return True, frontmatter, extract_skill_description(frontmatter)
except Exception as e:
logger.warning("Failed to parse skill file %s: %s", skill_file, e)
logger.debug("Failed to parse skill file %s: %s", skill_file, e)
return True, {}, ""
@@ -558,10 +558,9 @@ def build_skills_system_prompt(
# ── Layer 1: in-process LRU cache ─────────────────────────────────
# Include the resolved platform so per-platform disabled-skill lists
# produce distinct cache entries (gateway serves multiple platforms).
from gateway.session_context import get_session_env
_platform_hint = (
os.environ.get("HERMES_PLATFORM")
or get_session_env("HERMES_SESSION_PLATFORM")
or os.environ.get("HERMES_SESSION_PLATFORM")
or ""
)
cache_key = (
+1 -1
View File
@@ -168,7 +168,7 @@ def _build_skill_message(
subdir_path = skill_dir / subdir
if subdir_path.exists():
for f in sorted(subdir_path.rglob("*")):
if f.is_file() and not f.is_symlink():
if f.is_file():
rel = str(f.relative_to(skill_dir))
supporting.append(rel)
+1 -2
View File
@@ -145,11 +145,10 @@ def get_disabled_skill_names(platform: str | None = None) -> Set[str]:
if not isinstance(skills_cfg, dict):
return set()
from gateway.session_context import get_session_env
resolved_platform = (
platform
or os.getenv("HERMES_PLATFORM")
or get_session_env("HERMES_SESSION_PLATFORM")
or os.getenv("HERMES_SESSION_PLATFORM")
)
if resolved_platform:
platform_disabled = (skills_cfg.get("platform_disabled") or {}).get(
+2 -8
View File
@@ -480,12 +480,6 @@ agent:
# Fires once per run when inactivity reaches this threshold (seconds).
# Set to 0 to disable the warning.
# gateway_timeout_warning: 900
# Graceful drain timeout for gateway stop/restart (seconds).
# The gateway stops accepting new work, waits for in-flight agents to
# finish, then interrupts anything still running after this timeout.
# 0 = no drain, interrupt immediately.
# restart_drain_timeout: 60
# Enable verbose logging
verbose: false
@@ -588,7 +582,7 @@ platform_toolsets:
# skills_hub - skill_hub (search/install/manage from online registries — user-driven only)
# moa - mixture_of_agents (requires OPENROUTER_API_KEY)
# todo - todo (in-memory task planning, no deps)
# tts - text_to_speech (Edge TTS free, or ELEVENLABS/OPENAI/MINIMAX/MISTRAL key)
# tts - text_to_speech (Edge TTS free, or ELEVENLABS/OPENAI/MINIMAX key)
# cronjob - cronjob (create/list/update/pause/resume/run/remove scheduled tasks)
# rl - rl_list_environments, rl_start_training, etc. (requires TINKER_API_KEY)
#
@@ -617,7 +611,7 @@ platform_toolsets:
# todo - Task planning and tracking for multi-step work
# memory - Persistent memory across sessions (personal notes + user profile)
# session_search - Search and recall past conversations (FTS5 + Gemini Flash summarization)
# tts - Text-to-speech (Edge TTS free, ElevenLabs, OpenAI, MiniMax, Mistral)
# tts - Text-to-speech (Edge TTS free, ElevenLabs, OpenAI, MiniMax)
# cronjob - Schedule and manage automated tasks (CLI-only)
# rl - RL training tools (Tinker-Atropos)
#
+68 -157
View File
@@ -987,60 +987,11 @@ def _prune_orphaned_branches(repo_root: str) -> None:
# - Dim: #B8860B (muted text)
# ANSI building blocks for conversation display
_ACCENT_ANSI_DEFAULT = "\033[1;38;2;255;215;0m" # True-color #FFD700 bold — fallback
_GOLD = "\033[1;38;2;255;215;0m" # True-color #FFD700 bold — matches Rich Panel gold
_BOLD = "\033[1m"
_DIM = "\033[2m"
_RST = "\033[0m"
def _hex_to_ansi_bold(hex_color: str) -> str:
"""Convert a hex color like '#268bd2' to a bold true-color ANSI escape."""
try:
r = int(hex_color[1:3], 16)
g = int(hex_color[3:5], 16)
b = int(hex_color[5:7], 16)
return f"\033[1;38;2;{r};{g};{b}m"
except (ValueError, IndexError):
return _ACCENT_ANSI_DEFAULT
class _SkinAwareAnsi:
"""Lazy ANSI escape that resolves from the skin engine on first use.
Acts as a string in f-strings and concatenation. Call ``.reset()`` to
force re-resolution after a ``/skin`` switch.
"""
def __init__(self, skin_key: str, fallback_hex: str = "#FFD700"):
self._skin_key = skin_key
self._fallback_hex = fallback_hex
self._cached: str | None = None
def __str__(self) -> str:
if self._cached is None:
try:
from hermes_cli.skin_engine import get_active_skin
self._cached = _hex_to_ansi_bold(
get_active_skin().get_color(self._skin_key, self._fallback_hex)
)
except Exception:
self._cached = _hex_to_ansi_bold(self._fallback_hex)
return self._cached
def __add__(self, other: str) -> str:
return str(self) + other
def __radd__(self, other: str) -> str:
return other + str(self)
def reset(self) -> None:
"""Clear cache so the next access re-reads the skin."""
self._cached = None
_ACCENT = _SkinAwareAnsi("response_border", "#FFD700")
def _accent_hex() -> str:
"""Return the active skin accent color for legacy CLI output lines."""
try:
@@ -1171,45 +1122,6 @@ def _resolve_attachment_path(raw_path: str) -> Path | None:
return resolved
def _format_process_notification(evt: dict) -> "str | None":
"""Format a process notification event into a [SYSTEM: ...] message.
Handles both completion events (notify_on_complete) and watch pattern
match events from the unified completion_queue.
"""
evt_type = evt.get("type", "completion")
_sid = evt.get("session_id", "unknown")
_cmd = evt.get("command", "unknown")
if evt_type == "watch_disabled":
return f"[SYSTEM: {evt.get('message', '')}]"
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"watch pattern \"{_pat}\".\n"
f"Command: {_cmd}\n"
f"Matched output:\n{_out}"
)
if _sup:
text += f"\n({_sup} earlier matches were suppressed by rate limit)"
text += "]"
return text
# Default: completion event
_exit = evt.get("exit_code", "?")
_out = evt.get("output", "")
return (
f"[SYSTEM: Background process {_sid} completed "
f"(exit code {_exit}).\n"
f"Command: {_cmd}\n"
f"Output:\n{_out}]"
)
def _detect_file_drop(user_input: str) -> "dict | None":
"""Detect if *user_input* starts with a real local file path.
@@ -2554,7 +2466,7 @@ class HermesCLI:
self._stream_text_ansi = ""
w = shutil.get_terminal_size().columns
fill = w - 2 - len(label)
_cprint(f"\n{_ACCENT}╭─{label}{'' * max(fill - 1, 0)}{_RST}")
_cprint(f"\n{_GOLD}╭─{label}{'' * max(fill - 1, 0)}{_RST}")
self._stream_buf += text
@@ -2585,7 +2497,7 @@ class HermesCLI:
# Close the response box
if self._stream_box_opened:
w = shutil.get_terminal_size().columns
_cprint(f"{_ACCENT}{'' * (w - 2)}{_RST}")
_cprint(f"{_GOLD}{'' * (w - 2)}{_RST}")
def _reset_stream_state(self) -> None:
"""Reset streaming state before each agent invocation."""
@@ -3008,17 +2920,15 @@ class HermesCLI:
title_part = ""
if session_meta.get("title"):
title_part = f' "{session_meta["title"]}"'
accent_color = _accent_hex()
self.console.print(
f"[{accent_color}]↻ Resumed session [bold]{self.session_id}[/bold]"
f"[#DAA520]↻ Resumed session [bold]{self.session_id}[/bold]"
f"{title_part} "
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
f"{len(restored)} total messages)[/]"
)
else:
accent_color = _accent_hex()
self.console.print(
f"[{accent_color}]Session {self.session_id} found but has no "
f"[#DAA520]Session {self.session_id} found but has no "
f"messages. Starting fresh.[/]"
)
return False
@@ -3487,26 +3397,18 @@ class HermesCLI:
else:
api_indicator = "[red bold]●[/]"
# Build status line with proper markup — skin-aware colors
try:
from hermes_cli.skin_engine import get_active_skin
skin = get_active_skin()
separator_color = skin.get_color("banner_dim", "#B8860B")
accent_color = skin.get_color("ui_accent", "#FFBF00")
label_color = skin.get_color("ui_label", "#4dd0e1")
except Exception:
separator_color, accent_color, label_color = "#B8860B", "#FFBF00", "cyan"
# Build status line with proper markup
toolsets_info = ""
if self.enabled_toolsets and "all" not in self.enabled_toolsets:
toolsets_info = f" [dim {separator_color}]·[/] [{label_color}]toolsets: {', '.join(self.enabled_toolsets)}[/]"
toolsets_info = f" [dim #B8860B]·[/] [#CD7F32]toolsets: {', '.join(self.enabled_toolsets)}[/]"
provider_info = f" [dim {separator_color}]·[/] [dim]provider: {self.provider}[/]"
provider_info = f" [dim #B8860B]·[/] [dim]provider: {self.provider}[/]"
if self._provider_source:
provider_info += f" [dim {separator_color}]·[/] [dim]auth: {self._provider_source}[/]"
provider_info += f" [dim #B8860B]·[/] [dim]auth: {self._provider_source}[/]"
self.console.print(
f" {api_indicator} [{accent_color}]{model_short}[/] "
f"[dim {separator_color}]·[/] [bold {label_color}]{tool_count} tools[/]"
f" {api_indicator} [#FFBF00]{model_short}[/] "
f"[dim #B8860B]·[/] [bold cyan]{tool_count} tools[/]"
f"{toolsets_info}{provider_info}"
)
@@ -3697,7 +3599,7 @@ class HermesCLI:
# TUI event loop (known pitfall).
verb = "Disabling" if subcommand == "disable" else "Enabling"
label = ", ".join(names)
_cprint(f"{_ACCENT}{verb} {label}...{_RST}")
_cprint(f"{_GOLD}{verb} {label}...{_RST}")
tools_disable_enable_command(
Namespace(tools_action=subcommand, names=names, platform="cli"))
@@ -5210,17 +5112,17 @@ class HermesCLI:
if full_name == typed_base:
# Already an exact token — no expansion possible; fall through
_cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}")
_cprint(f"{_DIM}{_ACCENT}Type /help for available commands{_RST}")
_cprint(f"{_DIM}{_GOLD}Type /help for available commands{_RST}")
else:
remainder = cmd_original.strip()[len(typed_base):]
full_cmd = full_name + remainder
return self.process_command(full_cmd)
elif len(matches) > 1:
_cprint(f"{_ACCENT}Ambiguous command: {cmd_lower}{_RST}")
_cprint(f"{_GOLD}Ambiguous command: {cmd_lower}{_RST}")
_cprint(f"{_DIM}Did you mean: {', '.join(sorted(matches))}?{_RST}")
else:
_cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}")
_cprint(f"{_DIM}{_ACCENT}Type /help for available commands{_RST}")
_cprint(f"{_DIM}{_GOLD}Type /help for available commands{_RST}")
return True
@@ -5758,7 +5660,6 @@ class HermesCLI:
return
set_active_skin(new_skin)
_ACCENT.reset() # Re-resolve ANSI color for the new skin
if save_config_value("display.skin", new_skin):
print(f" Skin set to: {new_skin} (saved)")
else:
@@ -5827,8 +5728,8 @@ class HermesCLI:
else:
level = rc.get("effort", "medium")
display_state = "on ✓" if self.show_reasoning else "off"
_cprint(f" {_ACCENT}Reasoning effort: {level}{_RST}")
_cprint(f" {_ACCENT}Reasoning display: {display_state}{_RST}")
_cprint(f" {_GOLD}Reasoning effort: {level}{_RST}")
_cprint(f" {_GOLD}Reasoning display: {display_state}{_RST}")
_cprint(f" {_DIM}Usage: /reasoning <none|minimal|low|medium|high|xhigh|show|hide>{_RST}")
return
@@ -5840,7 +5741,7 @@ class HermesCLI:
if self.agent:
self.agent.reasoning_callback = self._current_reasoning_callback()
save_config_value("display.show_reasoning", True)
_cprint(f" {_ACCENT}✓ Reasoning display: ON (saved){_RST}")
_cprint(f" {_GOLD}✓ Reasoning display: ON (saved){_RST}")
_cprint(f" {_DIM} Model thinking will be shown during and after each response.{_RST}")
return
if arg in ("hide", "off"):
@@ -5848,7 +5749,7 @@ class HermesCLI:
if self.agent:
self.agent.reasoning_callback = self._current_reasoning_callback()
save_config_value("display.show_reasoning", False)
_cprint(f" {_ACCENT}✓ Reasoning display: OFF (saved){_RST}")
_cprint(f" {_GOLD}✓ Reasoning display: OFF (saved){_RST}")
return
# Effort level change
@@ -5863,9 +5764,9 @@ class HermesCLI:
self.agent = None # Force agent re-init with new reasoning config
if save_config_value("agent.reasoning_effort", arg):
_cprint(f" {_ACCENT}✓ Reasoning effort set to '{arg}' (saved to config){_RST}")
_cprint(f" {_GOLD}✓ Reasoning effort set to '{arg}' (saved to config){_RST}")
else:
_cprint(f" {_ACCENT}✓ Reasoning effort set to '{arg}' (session only){_RST}")
_cprint(f" {_GOLD}✓ Reasoning effort set to '{arg}' (session only){_RST}")
def _handle_fast_command(self, cmd: str):
"""Handle /fast — toggle fast mode (OpenAI Priority Processing / Anthropic Fast Mode)."""
@@ -5885,7 +5786,7 @@ class HermesCLI:
parts = cmd.strip().split(maxsplit=1)
if len(parts) < 2 or parts[1].strip().lower() == "status":
status = "fast" if self.service_tier == "priority" else "normal"
_cprint(f" {_ACCENT}{feature_name}: {status}{_RST}")
_cprint(f" {_GOLD}{feature_name}: {status}{_RST}")
_cprint(f" {_DIM}Usage: /fast [normal|fast|status]{_RST}")
return
@@ -5906,9 +5807,9 @@ class HermesCLI:
self.agent = None # Force agent re-init with new service-tier config
if save_config_value("agent.service_tier", saved_value):
_cprint(f" {_ACCENT}{feature_name} set to {label} (saved to config){_RST}")
_cprint(f" {_GOLD}{feature_name} set to {label} (saved to config){_RST}")
else:
_cprint(f" {_ACCENT}{feature_name} set to {label} (session only){_RST}")
_cprint(f" {_GOLD}{feature_name} set to {label} (session only){_RST}")
def _on_reasoning(self, reasoning_text: str):
"""Callback for intermediate reasoning display during tool-call loops."""
@@ -5934,29 +5835,21 @@ class HermesCLI:
original_count = len(self.conversation_history)
try:
from agent.model_metadata import estimate_messages_tokens_rough
from agent.manual_compression_feedback import summarize_manual_compression
original_history = list(self.conversation_history)
approx_tokens = estimate_messages_tokens_rough(original_history)
approx_tokens = estimate_messages_tokens_rough(self.conversation_history)
print(f"🗜️ Compressing {original_count} messages (~{approx_tokens:,} tokens)...")
compressed, _ = self.agent._compress_context(
original_history,
compressed, _new_system = self.agent._compress_context(
self.conversation_history,
self.agent._cached_system_prompt or "",
approx_tokens=approx_tokens,
)
self.conversation_history = compressed
new_count = len(self.conversation_history)
new_tokens = estimate_messages_tokens_rough(self.conversation_history)
summary = summarize_manual_compression(
original_history,
self.conversation_history,
approx_tokens,
new_tokens,
print(
f" ✅ Compressed: {original_count}{new_count} messages "
f"(~{approx_tokens:,} → ~{new_tokens:,} tokens)"
)
icon = "🗜️" if summary["noop"] else ""
print(f" {icon} {summary['headline']}")
print(f" {summary['token_line']}")
if summary["note"]:
print(f" {summary['note']}")
except Exception as e:
print(f" ❌ Compression failed: {e}")
@@ -6408,7 +6301,7 @@ class HermesCLI:
_recording_hint = "Termux:API capture | Ctrl+B to stop"
else:
_recording_hint = "Ctrl+B to stop"
_cprint(f"\n{_ACCENT}● Recording...{_RST} {_DIM}({_recording_hint}){_RST}")
_cprint(f"\n{_GOLD}● Recording...{_RST} {_DIM}({_recording_hint}){_RST}")
# Periodically refresh prompt to update audio level indicator
def _refresh_level():
@@ -6608,14 +6501,14 @@ class HermesCLI:
# Environment detection -- warn and block in incompatible environments
env_check = detect_audio_environment()
if not env_check["available"]:
_cprint(f"\n{_ACCENT}Voice mode unavailable in this environment:{_RST}")
_cprint(f"\n{_GOLD}Voice mode unavailable in this environment:{_RST}")
for warning in env_check["warnings"]:
_cprint(f" {_DIM}{warning}{_RST}")
return
reqs = check_voice_requirements()
if not reqs["available"]:
_cprint(f"\n{_ACCENT}Voice mode requirements not met:{_RST}")
_cprint(f"\n{_GOLD}Voice mode requirements not met:{_RST}")
for line in reqs["details"].split("\n"):
_cprint(f" {_DIM}{line}{_RST}")
if reqs["missing_packages"]:
@@ -6653,7 +6546,7 @@ class HermesCLI:
except Exception:
_ptt_key = "c-b"
_ptt_display = _ptt_key.replace("c-", "Ctrl+").upper()
_cprint(f"\n{_ACCENT}Voice mode enabled{tts_status}{_RST}")
_cprint(f"\n{_GOLD}Voice mode enabled{tts_status}{_RST}")
_cprint(f" {_DIM}{_ptt_display} to start/stop recording{_RST}")
_cprint(f" {_DIM}/voice tts to toggle speech output{_RST}")
_cprint(f" {_DIM}/voice off to disable voice mode{_RST}")
@@ -6705,7 +6598,7 @@ class HermesCLI:
if not check_tts_requirements():
_cprint(f"{_DIM}Warning: No TTS provider available. Install edge-tts or set API keys.{_RST}")
_cprint(f"{_ACCENT}Voice TTS {status}.{_RST}")
_cprint(f"{_GOLD}Voice TTS {status}.{_RST}")
def _show_voice_status(self):
"""Show current voice mode status."""
@@ -7190,7 +7083,7 @@ class HermesCLI:
w = self.console.width
label = " ⚕ Hermes "
fill = w - 2 - len(label)
_cprint(f"\n{_ACCENT}╭─{label}{'' * max(fill - 1, 0)}{_RST}")
_cprint(f"\n{_GOLD}╭─{label}{'' * max(fill - 1, 0)}{_RST}")
_cprint(sentence.rstrip())
tts_thread = threading.Thread(
@@ -7406,7 +7299,7 @@ class HermesCLI:
if use_streaming_tts and _streaming_box_opened and not is_error_response:
# Text was already printed sentence-by-sentence; just close the box
w = shutil.get_terminal_size().columns
_cprint(f"\n{_ACCENT}{'' * (w - 2)}{_RST}")
_cprint(f"\n{_GOLD}{'' * (w - 2)}{_RST}")
elif already_streamed:
# Response was already streamed token-by-token with box framing;
# _flush_stream() already closed the box. Skip Rich Panel.
@@ -8909,15 +8802,23 @@ class HermesCLI:
# Periodic config watcher — auto-reload MCP on mcp_servers change
if not self._agent_running:
self._check_config_mcp_changes()
# Check for background process notifications (completions
# and watch pattern matches) while agent is idle.
# Check for background process completion notifications
# while the agent is idle (user hasn't typed anything yet).
try:
from tools.process_registry import process_registry
if not process_registry.completion_queue.empty():
evt = process_registry.completion_queue.get_nowait()
_synth = _format_process_notification(evt)
if _synth:
self._pending_input.put(_synth)
completion = process_registry.completion_queue.get_nowait()
_exit = completion.get("exit_code", "?")
_cmd = completion.get("command", "unknown")
_sid = completion.get("session_id", "unknown")
_out = completion.get("output", "")
_synth = (
f"[SYSTEM: Background process {_sid} completed "
f"(exit code {_exit}).\n"
f"Command: {_cmd}\n"
f"Output:\n{_out}]"
)
self._pending_input.put(_synth)
except Exception:
pass
continue
@@ -9035,15 +8936,25 @@ class HermesCLI:
_cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}")
threading.Thread(target=_restart_recording, daemon=True).start()
# Drain process notifications (completions + watch matches)
# that arrived while the agent was running.
# Drain process completion notifications — any background
# process that finished with notify_on_complete while the
# agent was running (or before) gets auto-injected as a
# new user message so the agent can react to it.
try:
from tools.process_registry import process_registry
while not process_registry.completion_queue.empty():
evt = process_registry.completion_queue.get_nowait()
_synth = _format_process_notification(evt)
if _synth:
self._pending_input.put(_synth)
completion = process_registry.completion_queue.get_nowait()
_exit = completion.get("exit_code", "?")
_cmd = completion.get("command", "unknown")
_sid = completion.get("session_id", "unknown")
_out = completion.get("output", "")
_synth = (
f"[SYSTEM: Background process {_sid} completed "
f"(exit code {_exit}).\n"
f"Command: {_cmd}\n"
f"Output:\n{_out}]"
)
self._pending_input.put(_synth)
except Exception:
pass # Non-fatal — don't break the main loop
+7 -10
View File
@@ -31,7 +31,7 @@ except ImportError:
# Configuration
# =============================================================================
HERMES_DIR = get_hermes_home().resolve()
HERMES_DIR = get_hermes_home()
CRON_DIR = HERMES_DIR / "cron"
JOBS_FILE = CRON_DIR / "jobs.json"
OUTPUT_DIR = CRON_DIR / "output"
@@ -338,12 +338,10 @@ def load_jobs() -> List[Dict[str, Any]]:
save_jobs(jobs)
logger.warning("Auto-repaired jobs.json (had invalid control characters)")
return jobs
except Exception as e:
logger.error("Failed to auto-repair jobs.json: %s", e)
raise RuntimeError(f"Cron database corrupted and unrepairable: {e}") from e
except IOError as e:
logger.error("IOError reading jobs.json: %s", e)
raise RuntimeError(f"Failed to read cron database: {e}") from e
except Exception:
return []
except IOError:
return []
def save_jobs(jobs: List[Dict[str, Any]]):
@@ -454,7 +452,6 @@ def create_job(
"last_run_at": None,
"last_status": None,
"last_error": None,
"last_delivery_error": None,
# Delivery configuration
"deliver": deliver,
"origin": origin, # Tracks where job was created for "origin" delivery
@@ -623,8 +620,8 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None,
save_jobs(jobs)
return
logger.warning("mark_job_run: job_id %s not found, skipping save", job_id)
save_jobs(jobs)
def advance_next_run(job_id: str) -> bool:
+8 -9
View File
@@ -442,14 +442,6 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
stdout = (result.stdout or "").strip()
stderr = (result.stderr or "").strip()
# Redact secrets from both stdout and stderr before any return path.
try:
from agent.redact import redact_sensitive_text
stdout = redact_sensitive_text(stdout)
stderr = redact_sensitive_text(stderr)
except Exception:
pass
if result.returncode != 0:
parts = [f"Script exited with code {result.returncode}"]
if stderr:
@@ -458,6 +450,13 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
parts.append(f"stdout:\n{stdout}")
return False, "\n".join(parts)
# Redact any secrets that may appear in script output before
# they are injected into the LLM prompt context.
try:
from agent.redact import redact_sensitive_text
stdout = redact_sensitive_text(stdout)
except Exception:
pass
return True, stdout
except subprocess.TimeoutExpired:
@@ -770,7 +769,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
_cron_pool.shutdown(wait=False, cancel_futures=True)
raise
finally:
_cron_pool.shutdown(wait=False, cancel_futures=True)
_cron_pool.shutdown(wait=False)
if _inactivity_timeout:
# Build diagnostic summary from the agent's activity tracker.
+1 -1
View File
@@ -12,7 +12,7 @@ INSTALL_DIR="/opt/hermes"
# The "home/" subdirectory is a per-profile HOME for subprocesses (git,
# ssh, gh, npm …). Without it those tools write to /root which is
# ephemeral and shared across profiles. See issue #4426.
mkdir -p "$HERMES_HOME"/{cron,sessions,logs,hooks,memories,skills,skins,plans,workspace,home}
mkdir -p "$HERMES_HOME"/{cron,sessions,logs,hooks,memories,skills,home}
# .env
if [ ! -f "$HERMES_HOME/.env" ]; then
@@ -49,8 +49,6 @@ class HermesToolCallParser(ToolCallParser):
continue
tc_data = json.loads(raw_json)
if "name" not in tc_data:
continue
tool_calls.append(
ChatCompletionMessageToolCall(
id=f"call_{uuid.uuid4().hex[:8]}",
@@ -89,8 +89,6 @@ class MistralToolCallParser(ToolCallParser):
parsed = [parsed]
for tc in parsed:
if "name" not in tc:
continue
args = tc.get("arguments", {})
if isinstance(args, dict):
args = json.dumps(args, ensure_ascii=False)
+4 -9
View File
@@ -76,15 +76,10 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
except Exception as e:
logger.warning("Channel directory: failed to build %s: %s", platform.value, e)
# Platforms that don't support direct channel enumeration get session-based
# discovery automatically. Skip infrastructure entries that aren't messaging
# platforms — everything else falls through to _build_from_sessions().
_SKIP_SESSION_DISCOVERY = frozenset({"local", "api_server", "webhook"})
for plat in Platform:
plat_name = plat.value
if plat_name in _SKIP_SESSION_DISCOVERY or plat_name in platforms:
continue
platforms[plat_name] = _build_from_sessions(plat_name)
# Telegram, WhatsApp & Signal can't enumerate chats -- pull from session history
for plat_name in ("telegram", "whatsapp", "signal", "weixin", "email", "sms", "bluebubbles"):
if plat_name not in platforms:
platforms[plat_name] = _build_from_sessions(plat_name)
directory = {
"updated_at": datetime.now().isoformat(),
-2
View File
@@ -642,8 +642,6 @@ def load_gateway_config() -> GatewayConfig:
os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc)
if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
os.environ["MATRIX_DM_MENTION_THREADS"] = str(matrix_cfg["dm_mention_threads"]).lower()
except Exception as e:
logger.warning(
+18 -50
View File
@@ -55,6 +55,7 @@ MAX_STORED_RESPONSES = 100
MAX_REQUEST_BYTES = 1_000_000 # 1 MB default limit for POST bodies
def check_api_server_requirements() -> bool:
"""Check if API server dependencies are available."""
return AIOHTTP_AVAILABLE
@@ -409,7 +410,7 @@ class APIServerAdapter(BasePlatformAdapter):
Returns None if auth is OK, or a 401 web.Response on failure.
If no API key is configured, all requests are allowed (only when API
server is local).
server is local)
"""
if not self._api_key:
return None # No key configured — allow all (local-only use)
@@ -644,35 +645,15 @@ class APIServerAdapter(BasePlatformAdapter):
_stream_q.put(delta)
def _on_tool_progress(event_type, name, preview, args, **kwargs):
"""Send tool progress as a separate SSE event.
Previously, progress markers like ``⏰ list`` were injected
directly into ``delta.content``. OpenAI-compatible frontends
(Open WebUI, LobeChat, …) store ``delta.content`` verbatim as
the assistant message and send it back on subsequent requests.
After enough turns the model learns to *emit* the markers as
plain text instead of issuing real tool calls — silently
hallucinating tool results. See #6972.
The fix: push a tagged tuple ``("__tool_progress__", payload)``
onto the stream queue. The SSE writer emits it as a custom
``event: hermes.tool.progress`` line that compliant frontends
can render for UX but will *not* persist into conversation
history. Clients that don't understand the custom event type
silently ignore it per the SSE specification.
"""
"""Inject tool progress into the SSE stream for Open WebUI."""
if event_type != "tool.started":
return
return # Only show tool start events in chat stream
if name.startswith("_"):
return
return # Skip internal events (_thinking)
from agent.display import get_tool_emoji
emoji = get_tool_emoji(name)
label = preview or name
_stream_q.put(("__tool_progress__", {
"tool": name,
"emoji": emoji,
"label": label,
}))
_stream_q.put(f"\n`{emoji} {label}`\n")
# Start agent in background. agent_ref is a mutable container
# so the SSE writer can interrupt the agent on client disconnect.
@@ -783,29 +764,6 @@ class APIServerAdapter(BasePlatformAdapter):
}
await response.write(f"data: {json.dumps(role_chunk)}\n\n".encode())
# Helper — route a queue item to the correct SSE event.
async def _emit(item):
"""Write a single queue item to the SSE stream.
Plain strings are sent as normal ``delta.content`` chunks.
Tagged tuples ``("__tool_progress__", payload)`` are sent
as a custom ``event: hermes.tool.progress`` SSE event so
frontends can display them without storing the markers in
conversation history. See #6972.
"""
if isinstance(item, tuple) and len(item) == 2 and item[0] == "__tool_progress__":
event_data = json.dumps(item[1])
await response.write(
f"event: hermes.tool.progress\ndata: {event_data}\n\n".encode()
)
else:
content_chunk = {
"id": completion_id, "object": "chat.completion.chunk",
"created": created, "model": model,
"choices": [{"index": 0, "delta": {"content": item}, "finish_reason": None}],
}
await response.write(f"data: {json.dumps(content_chunk)}\n\n".encode())
# Stream content chunks as they arrive from the agent
loop = asyncio.get_event_loop()
while True:
@@ -819,7 +777,12 @@ class APIServerAdapter(BasePlatformAdapter):
delta = stream_q.get_nowait()
if delta is None:
break
await _emit(delta)
content_chunk = {
"id": completion_id, "object": "chat.completion.chunk",
"created": created, "model": model,
"choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}],
}
await response.write(f"data: {json.dumps(content_chunk)}\n\n".encode())
except _q.Empty:
break
break
@@ -828,7 +791,12 @@ class APIServerAdapter(BasePlatformAdapter):
if delta is None: # End of stream sentinel
break
await _emit(delta)
content_chunk = {
"id": completion_id, "object": "chat.completion.chunk",
"created": created, "model": model,
"choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}],
}
await response.write(f"data: {json.dumps(content_chunk)}\n\n".encode())
# Get usage from completed agent
usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
+9 -40
View File
@@ -673,32 +673,6 @@ class SendResult:
retryable: bool = False # True for transient connection errors — base will retry automatically
def merge_pending_message_event(
pending_messages: Dict[str, MessageEvent],
session_key: str,
event: MessageEvent,
) -> None:
"""Store or merge a pending event for a session.
Photo bursts/albums often arrive as multiple near-simultaneous PHOTO
events. Merge those into the existing queued event so the next turn sees
the whole burst, while non-photo follow-ups still replace the pending
event normally.
"""
existing = pending_messages.get(session_key)
if (
existing
and getattr(existing, "message_type", None) == MessageType.PHOTO
and event.message_type == MessageType.PHOTO
):
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
if event.text:
existing.text = BasePlatformAdapter._merge_caption(existing.text, event.text)
return
pending_messages[session_key] = event
# Error substrings that indicate a transient *connection* failure worth retrying.
# "timeout" / "timed out" / "readtimeout" / "writetimeout" are intentionally
# excluded: a read/write timeout on a non-idempotent call (e.g. send_message)
@@ -753,7 +727,6 @@ class BasePlatformAdapter(ABC):
# working on a task after --replace or manual restarts.
self._background_tasks: set[asyncio.Task] = set()
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)
self._auto_tts_disabled_chats: set = set()
# Chats where typing indicator is paused (e.g. during approval waits).
@@ -842,10 +815,6 @@ class BasePlatformAdapter(ABC):
an optional response string.
"""
self._message_handler = handler
def set_busy_session_handler(self, handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]]) -> None:
"""Set an optional handler for messages arriving during active sessions."""
self._busy_session_handler = handler
def set_session_store(self, session_store: Any) -> None:
"""
@@ -1427,7 +1396,7 @@ class BasePlatformAdapter(ABC):
# session lifecycle and its cleanup races with the running task
# (see PR #4926).
cmd = event.get_command()
if cmd in ("approve", "deny", "status", "stop", "new", "reset", "background", "restart"):
if cmd in ("approve", "deny", "status", "stop", "new", "reset", "background"):
logger.debug(
"[%s] Command '/%s' bypassing active-session guard for %s",
self.name, cmd, session_key,
@@ -1446,19 +1415,19 @@ class BasePlatformAdapter(ABC):
logger.error("[%s] Command '/%s' dispatch failed: %s", self.name, cmd, e, exc_info=True)
return
if self._busy_session_handler is not None:
try:
if await self._busy_session_handler(event, session_key):
return
except Exception as e:
logger.error("[%s] Busy-session handler failed: %s", self.name, e, exc_info=True)
# Special case: photo bursts/albums frequently arrive as multiple near-
# simultaneous messages. Queue them without interrupting the active run,
# then process them immediately after the current task finishes.
if event.message_type == MessageType.PHOTO:
logger.debug("[%s] Queuing photo follow-up for session %s without interrupt", self.name, session_key)
merge_pending_message_event(self._pending_messages, session_key, event)
existing = self._pending_messages.get(session_key)
if existing and existing.message_type == MessageType.PHOTO:
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
if event.text:
existing.text = self._merge_caption(existing.text, event.text)
else:
self._pending_messages[session_key] = event
return # Don't interrupt now - will run after current task completes
# Default behavior for non-photo follow-ups: interrupt the running agent
-2
View File
@@ -1190,8 +1190,6 @@ class FeishuAdapter(BasePlatformAdapter):
lambda data: self._on_reaction_event("im.message.reaction.deleted_v1", data)
)
.register_p2_card_action_trigger(self._on_card_action_trigger)
.register_p2_im_chat_member_bot_added_v1(self._on_bot_added_to_chat)
.register_p2_im_chat_member_bot_deleted_v1(self._on_bot_removed_from_chat)
.build()
)
File diff suppressed because it is too large Load Diff
-20
View File
@@ -1,20 +0,0 @@
"""Shared gateway restart constants and parsing helpers."""
from hermes_cli.config import DEFAULT_CONFIG
# EX_TEMPFAIL from sysexits.h — used to ask the service manager to restart
# the gateway after a graceful drain/reload path completes.
GATEWAY_SERVICE_RESTART_EXIT_CODE = 75
DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT = float(
DEFAULT_CONFIG["agent"]["restart_drain_timeout"]
)
def parse_restart_drain_timeout(raw: object) -> float:
"""Parse a configured drain timeout, falling back to the shared default."""
try:
value = float(raw) if str(raw or "").strip() else DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
except (TypeError, ValueError):
return DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
return max(0.0, value)
+145 -675
View File
File diff suppressed because it is too large Load Diff
-113
View File
@@ -1,113 +0,0 @@
"""
Session-scoped context variables for the Hermes gateway.
Replaces the previous ``os.environ``-based session state
(``HERMES_SESSION_PLATFORM``, ``HERMES_SESSION_CHAT_ID``, etc.) with
Python's ``contextvars.ContextVar``.
**Why this matters**
The gateway processes messages concurrently via ``asyncio``. When two
messages arrive at the same time the old code did:
os.environ["HERMES_SESSION_THREAD_ID"] = str(context.source.thread_id)
Because ``os.environ`` is *process-global*, Message A's value was
silently overwritten by Message B before Message A's agent finished
running. Background-task notifications and tool calls therefore routed
to the wrong thread.
``contextvars.ContextVar`` values are *task-local*: each ``asyncio``
task (and any ``run_in_executor`` thread it spawns) gets its own copy,
so concurrent messages never interfere.
**Backward compatibility**
The public helper ``get_session_env(name, default="")`` mirrors the old
``os.getenv("HERMES_SESSION_*", ...)`` calls. Existing tool code only
needs to replace the import + call site:
# before
import os
platform = os.getenv("HERMES_SESSION_PLATFORM", "")
# after
from gateway.session_context import get_session_env
platform = get_session_env("HERMES_SESSION_PLATFORM", "")
"""
from contextvars import ContextVar
# ---------------------------------------------------------------------------
# Per-task session variables
# ---------------------------------------------------------------------------
_SESSION_PLATFORM: ContextVar[str] = ContextVar("HERMES_SESSION_PLATFORM", default="")
_SESSION_CHAT_ID: ContextVar[str] = ContextVar("HERMES_SESSION_CHAT_ID", default="")
_SESSION_CHAT_NAME: ContextVar[str] = ContextVar("HERMES_SESSION_CHAT_NAME", default="")
_SESSION_THREAD_ID: ContextVar[str] = ContextVar("HERMES_SESSION_THREAD_ID", default="")
_VAR_MAP = {
"HERMES_SESSION_PLATFORM": _SESSION_PLATFORM,
"HERMES_SESSION_CHAT_ID": _SESSION_CHAT_ID,
"HERMES_SESSION_CHAT_NAME": _SESSION_CHAT_NAME,
"HERMES_SESSION_THREAD_ID": _SESSION_THREAD_ID,
}
def set_session_vars(
platform: str = "",
chat_id: str = "",
chat_name: str = "",
thread_id: str = "",
) -> list:
"""Set all session context variables and return reset tokens.
Call ``clear_session_vars(tokens)`` in a ``finally`` block to restore
the previous values when the handler exits.
Returns a list of ``Token`` objects (one per variable) that can be
passed to ``clear_session_vars``.
"""
tokens = [
_SESSION_PLATFORM.set(platform),
_SESSION_CHAT_ID.set(chat_id),
_SESSION_CHAT_NAME.set(chat_name),
_SESSION_THREAD_ID.set(thread_id),
]
return tokens
def clear_session_vars(tokens: list) -> None:
"""Restore session context variables to their pre-handler values."""
if not tokens:
return
vars_in_order = [
_SESSION_PLATFORM,
_SESSION_CHAT_ID,
_SESSION_CHAT_NAME,
_SESSION_THREAD_ID,
]
for var, token in zip(vars_in_order, tokens):
var.reset(token)
def get_session_env(name: str, default: str = "") -> str:
"""Read a session context variable by its legacy ``HERMES_SESSION_*`` name.
Drop-in replacement for ``os.getenv("HERMES_SESSION_*", default)``.
Resolution order:
1. Context variable (set by the gateway for concurrency-safe access)
2. ``os.environ`` (used by CLI, cron scheduler, and tests)
3. *default*
"""
import os
var = _VAR_MAP.get(name)
if var is not None:
value = var.get()
if value:
return value
# Fall back to os.environ for CLI, cron, and test compatibility
return os.getenv(name, default)
-8
View File
@@ -158,8 +158,6 @@ def _build_runtime_status_record() -> dict[str, Any]:
payload.update({
"gateway_state": "starting",
"exit_reason": None,
"restart_requested": False,
"active_agents": 0,
"platforms": {},
"updated_at": _utc_now_iso(),
})
@@ -220,8 +218,6 @@ def write_runtime_status(
*,
gateway_state: Optional[str] = None,
exit_reason: Optional[str] = None,
restart_requested: Optional[bool] = None,
active_agents: Optional[int] = None,
platform: Optional[str] = None,
platform_state: Optional[str] = None,
error_code: Optional[str] = None,
@@ -240,10 +236,6 @@ def write_runtime_status(
payload["gateway_state"] = gateway_state
if exit_reason is not None:
payload["exit_reason"] = exit_reason
if restart_requested is not None:
payload["restart_requested"] = bool(restart_requested)
if active_agents is not None:
payload["active_agents"] = max(0, int(active_agents))
if platform is not None:
platform_payload = payload["platforms"].get(platform, {})
+16 -2
View File
@@ -19,10 +19,11 @@ import subprocess
import sys
from pathlib import Path
from hermes_constants import is_wsl as _is_wsl
logger = logging.getLogger(__name__)
# Cache WSL detection (checked once per process)
_wsl_detected: bool | None = None
def save_clipboard_image(dest: Path) -> bool:
"""Extract an image from the system clipboard and save it as PNG.
@@ -216,6 +217,19 @@ def _windows_save(dest: Path) -> bool:
# ── Linux ────────────────────────────────────────────────────────────────
def _is_wsl() -> bool:
"""Detect if running inside WSL (1 or 2)."""
global _wsl_detected
if _wsl_detected is not None:
return _wsl_detected
try:
with open("/proc/version", "r") as f:
_wsl_detected = "microsoft" in f.read().lower()
except Exception:
_wsl_detected = False
return _wsl_detected
def _linux_save(dest: Path) -> bool:
"""Try clipboard backends in priority order: WSL → Wayland → X11."""
if _is_wsl():
-2
View File
@@ -140,8 +140,6 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("commands", "Browse all commands and skills (paginated)", "Info",
gateway_only=True, args_hint="[page]"),
CommandDef("help", "Show available commands", "Info"),
CommandDef("restart", "Gracefully restart the gateway after draining active runs", "Session",
gateway_only=True),
CommandDef("usage", "Show token usage and rate limits for the current session", "Info"),
CommandDef("insights", "Show usage insights and analytics", "Info",
args_hint="[days]"),
+2 -34
View File
@@ -269,11 +269,6 @@ DEFAULT_CONFIG = {
# tools or receiving API responses. Only fires when the agent has
# been completely idle for this duration. 0 = unlimited.
"gateway_timeout": 1800,
# Graceful drain timeout for gateway stop/restart (seconds).
# The gateway stops accepting new work, waits for running agents
# to finish, then interrupts any remaining runs after the timeout.
# 0 = no drain, interrupt immediately.
"restart_drain_timeout": 60,
"service_tier": "",
# Tool-use enforcement: injects system prompt guidance that tells the
# model to actually call tools instead of describing intended actions.
@@ -458,7 +453,7 @@ DEFAULT_CONFIG = {
# Text-to-speech configuration
"tts": {
"provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "minimax" | "mistral" | "neutts" (local)
"provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "neutts" (local)
"edge": {
"voice": "en-US-AriaNeural",
# Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural
@@ -472,10 +467,6 @@ DEFAULT_CONFIG = {
"voice": "alloy",
# Voices: alloy, echo, fable, onyx, nova, shimmer
},
"mistral": {
"model": "voxtral-mini-tts-2603",
"voice_id": "c69964a6-ab8b-4f8a-9465-ec0925096ec8", # Paul - Neutral
},
"neutts": {
"ref_audio": "", # Path to reference voice audio (empty = bundled default)
"ref_text": "", # Path to reference voice transcript (empty = bundled default)
@@ -513,16 +504,6 @@ DEFAULT_CONFIG = {
"max_ms": 2500,
},
# Context engine -- controls how the context window is managed when
# approaching the model's token limit.
# "compressor" = built-in lossy summarization (default).
# Set to a plugin name to activate an alternative engine (e.g. "lcm"
# for Lossless Context Management). The engine must be installed as
# a plugin in plugins/context_engine/<name>/ or ~/.hermes/plugins/.
"context": {
"engine": "compressor",
},
# Persistent memory -- bounded curated memory injected into system prompt
"memory": {
"memory_enabled": True,
@@ -547,8 +528,6 @@ DEFAULT_CONFIG = {
"api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY)
"max_iterations": 50, # per-subagent iteration cap (each subagent gets its own budget,
# independent of the parent's max_iterations)
"reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium",
# "low", "minimal", "none" (empty = inherit parent's level)
},
# Ephemeral prefill messages file — JSON list of {role, content} dicts
@@ -1020,13 +999,6 @@ OPTIONAL_ENV_VARS = {
"password": True,
"category": "tool",
},
"MISTRAL_API_KEY": {
"description": "Mistral API key for Voxtral TTS and transcription (STT)",
"prompt": "Mistral API key",
"url": "https://console.mistral.ai/",
"password": True,
"category": "tool",
},
"GITHUB_TOKEN": {
"description": "GitHub token for Skills Hub (higher API rate limits, skill publish)",
"prompt": "GitHub Token",
@@ -1478,7 +1450,7 @@ _KNOWN_ROOT_KEYS = {
"_config_version", "model", "providers", "fallback_model",
"fallback_providers", "credential_pool_strategies", "toolsets",
"agent", "terminal", "display", "compression", "delegation",
"auxiliary", "custom_providers", "context", "memory", "gateway",
"auxiliary", "custom_providers", "memory", "gateway",
}
# Valid fields inside a custom_providers list entry
@@ -2801,10 +2773,6 @@ def set_config_value(key: str, value: str):
"terminal.timeout": "TERMINAL_TIMEOUT",
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
"terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL",
"terminal.container_cpu": "TERMINAL_CONTAINER_CPU",
"terminal.container_memory": "TERMINAL_CONTAINER_MEMORY",
"terminal.container_disk": "TERMINAL_CONTAINER_DISK",
"terminal.container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
}
if key in _config_to_env_sync:
save_env_value(_config_to_env_sync[key], str(value))
-127
View File
@@ -160,133 +160,6 @@ def curses_checklist(
return _numbered_fallback(title, items, selected, cancel_returns, status_fn)
def curses_radiolist(
title: str,
items: List[str],
selected: int = 0,
*,
cancel_returns: int | None = None,
) -> int:
"""Curses single-select radio list. Returns the selected index.
Args:
title: Header line displayed above the list.
items: Display labels for each row.
selected: Index that starts selected (pre-selected).
cancel_returns: Returned on ESC/q. Defaults to the original *selected*.
"""
if cancel_returns is None:
cancel_returns = selected
if not sys.stdin.isatty():
return cancel_returns
try:
import curses
result_holder: list = [None]
def _draw(stdscr):
curses.curs_set(0)
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
cursor = selected
scroll_offset = 0
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
# Header
try:
hattr = curses.A_BOLD
if curses.has_colors():
hattr |= curses.color_pair(2)
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
stdscr.addnstr(
1, 0,
" \u2191\u2193 navigate ENTER/SPACE select ESC cancel",
max_x - 1, curses.A_DIM,
)
except curses.error:
pass
# Scrollable item list
visible_rows = max_y - 4
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
scroll_offset = cursor - visible_rows + 1
for draw_i, i in enumerate(
range(scroll_offset, min(len(items), scroll_offset + visible_rows))
):
y = draw_i + 3
if y >= max_y - 1:
break
radio = "\u25cf" if i == selected else "\u25cb"
arrow = "\u2192" if i == cursor else " "
line = f" {arrow} ({radio}) {items[i]}"
attr = curses.A_NORMAL
if i == cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass
stdscr.refresh()
key = stdscr.getch()
if key in (curses.KEY_UP, ord("k")):
cursor = (cursor - 1) % len(items)
elif key in (curses.KEY_DOWN, ord("j")):
cursor = (cursor + 1) % len(items)
elif key in (ord(" "), curses.KEY_ENTER, 10, 13):
result_holder[0] = cursor
return
elif key in (27, ord("q")):
result_holder[0] = cancel_returns
return
curses.wrapper(_draw)
flush_stdin()
return result_holder[0] if result_holder[0] is not None else cancel_returns
except Exception:
return _radio_numbered_fallback(title, items, selected, cancel_returns)
def _radio_numbered_fallback(
title: str,
items: List[str],
selected: int,
cancel_returns: int,
) -> int:
"""Text-based numbered fallback for radio selection."""
print(color(f"\n {title}", Colors.YELLOW))
print(color(" Select by number, Enter to confirm.\n", Colors.DIM))
for i, label in enumerate(items):
marker = color("(\u25cf)", Colors.GREEN) if i == selected else "(\u25cb)"
print(f" {marker} {i + 1:>2}. {label}")
print()
try:
val = input(color(f" Choice [default {selected + 1}]: ", Colors.DIM)).strip()
if not val:
return selected
idx = int(val) - 1
if 0 <= idx < len(items):
return idx
return selected
except (ValueError, KeyboardInterrupt, EOFError):
return cancel_returns
def _numbered_fallback(
title: str,
items: List[str],
+3 -8
View File
@@ -722,9 +722,9 @@ def run_doctor(args):
("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True),
("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True),
("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True),
# MiniMax: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.
("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True),
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", True),
# MiniMax APIs don't support /models endpoint — https://github.com/NousResearch/hermes-agent/issues/811
("MiniMax", ("MINIMAX_API_KEY",), None, "MINIMAX_BASE_URL", False),
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), None, "MINIMAX_CN_BASE_URL", False),
("AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True),
("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True),
@@ -749,11 +749,6 @@ def run_doctor(args):
# Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com
if not _base and _key.startswith("sk-kimi-"):
_base = "https://api.kimi.com/coding/v1"
# Anthropic-compat endpoints (/anthropic) don't support /models.
# Rewrite to the OpenAI-compat /v1 surface for health checks.
if _base and _base.rstrip("/").endswith("/anthropic"):
from agent.auxiliary_client import _to_openai_base_url
_base = _to_openai_base_url(_base)
_url = (_base.rstrip("/") + "/models") if _base else _default_url
_headers = {"Authorization": f"Bearer {_key}"}
if "api.kimi.com" in _url.lower():
+22 -192
View File
@@ -15,19 +15,7 @@ from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from gateway.status import terminate_pid
from gateway.restart import (
DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT,
GATEWAY_SERVICE_RESTART_EXIT_CODE,
parse_restart_drain_timeout,
)
from hermes_cli.config import (
get_env_value,
get_hermes_home,
is_managed,
managed_error,
read_raw_config,
save_env_value,
)
from hermes_cli.config import get_env_value, get_hermes_home, save_env_value, is_managed, managed_error
# display_hermes_home is imported lazily at call sites to avoid ImportError
# when hermes_constants is cached from a pre-update version during `hermes update`.
from hermes_cli.setup import (
@@ -104,59 +92,6 @@ def _get_service_pids() -> set:
return pids
def _get_parent_pid(pid: int) -> int | None:
"""Return the parent PID for ``pid``, or ``None`` when unavailable."""
if pid <= 1:
return None
try:
result = subprocess.run(
["ps", "-o", "ppid=", "-p", str(pid)],
capture_output=True,
text=True,
timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
if result.returncode != 0:
return None
raw = result.stdout.strip()
if not raw:
return None
try:
parent_pid = int(raw.splitlines()[-1].strip())
except ValueError:
return None
return parent_pid if parent_pid > 0 else None
def _is_pid_ancestor_of_current_process(target_pid: int) -> bool:
"""Return True when ``target_pid`` is this process or one of its ancestors."""
if target_pid <= 0:
return False
pid = os.getpid()
seen: set[int] = set()
while pid and pid not in seen:
if pid == target_pid:
return True
seen.add(pid)
pid = _get_parent_pid(pid) or 0
return False
def _request_gateway_self_restart(pid: int) -> bool:
"""Ask a running gateway ancestor to restart itself asynchronously."""
if not hasattr(signal, "SIGUSR1"):
return False
if not _is_pid_ancestor_of_current_process(pid):
return False
try:
os.kill(pid, signal.SIGUSR1)
except (ProcessLookupError, PermissionError, OSError):
return False
return True
def find_gateway_pids(exclude_pids: set | None = None) -> list:
"""Find PIDs of running gateway processes.
@@ -291,33 +226,11 @@ def is_linux() -> bool:
return sys.platform.startswith('linux')
from hermes_constants import is_termux, is_wsl
def _wsl_systemd_operational() -> bool:
"""Check if systemd is actually running as PID 1 on WSL.
WSL2 with ``systemd=true`` in wsl.conf has working systemd.
WSL2 without it (or WSL1) does not systemctl commands fail.
"""
try:
result = subprocess.run(
["systemctl", "is-system-running"],
capture_output=True, text=True, timeout=5,
)
# "running", "degraded", "starting" all mean systemd is PID 1
status = result.stdout.strip().lower()
return status in ("running", "degraded", "starting", "initializing")
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return False
from hermes_constants import is_termux
def supports_systemd_services() -> bool:
if not is_linux() or is_termux():
return False
if is_wsl():
return _wsl_systemd_operational()
return True
return is_linux() and not is_termux()
def is_macos() -> bool:
@@ -752,7 +665,6 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
path_entries.append(resolved_node_dir)
common_bin_paths = ["/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin"]
restart_timeout = max(60, int(_get_restart_drain_timeout() or 0))
if system:
username, group_name, home_dir = _system_service_identity(run_as_user)
@@ -791,11 +703,9 @@ Environment="VIRTUAL_ENV={venv_dir}"
Environment="HERMES_HOME={hermes_home}"
Restart=on-failure
RestartSec=30
RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}
KillMode=mixed
KillSignal=SIGTERM
ExecReload=/bin/kill -USR1 $MAINPID
TimeoutStopSec={restart_timeout}
TimeoutStopSec=60
StandardOutput=journal
StandardError=journal
@@ -823,11 +733,9 @@ Environment="VIRTUAL_ENV={venv_dir}"
Environment="HERMES_HOME={hermes_home}"
Restart=on-failure
RestartSec=30
RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}
KillMode=mixed
KillSignal=SIGTERM
ExecReload=/bin/kill -USR1 $MAINPID
TimeoutStopSec={restart_timeout}
TimeoutStopSec=60
StandardOutput=journal
StandardError=journal
@@ -930,20 +838,6 @@ def _select_systemd_scope(system: bool = False) -> bool:
return get_systemd_unit_path(system=True).exists() and not get_systemd_unit_path(system=False).exists()
def _get_restart_drain_timeout() -> float:
"""Return the configured gateway restart drain timeout in seconds."""
raw = os.getenv("HERMES_RESTART_DRAIN_TIMEOUT", "").strip()
if not raw:
cfg = read_raw_config()
agent_cfg = cfg.get("agent", {}) if isinstance(cfg, dict) else {}
raw = str(
agent_cfg.get(
"restart_drain_timeout", DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
)
)
return parse_restart_drain_timeout(raw)
def systemd_install(force: bool = False, system: bool = False, run_as_user: str | None = None):
if system:
_require_root_for_system_service("install")
@@ -1029,13 +923,7 @@ def systemd_restart(system: bool = False):
if system:
_require_root_for_system_service("restart")
refresh_systemd_unit_if_needed(system=system)
from gateway.status import get_running_pid
pid = get_running_pid()
if pid is not None and _request_gateway_self_restart(pid):
print(f"{_service_scope_label(system).capitalize()} service restart requested")
return
subprocess.run(_systemctl_cmd(system) + ["reload-or-restart", get_service_name()], check=True, timeout=90)
subprocess.run(_systemctl_cmd(system) + ["restart", get_service_name()], check=True, timeout=90)
print(f"{_service_scope_label(system).capitalize()} service restarted")
@@ -1323,7 +1211,7 @@ def launchd_stop():
_wait_for_gateway_exit(timeout=10.0, force_after=5.0)
print("✓ Service stopped")
def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float | None = 5.0) -> bool:
def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float = 5.0):
"""Wait for the gateway process (by saved PID) to exit.
Uses the PID from the gateway.pid file not launchd labels so this
@@ -1338,21 +1226,21 @@ def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float | None = 5.
from gateway.status import get_running_pid
deadline = time.monotonic() + timeout
force_deadline = (time.monotonic() + force_after) if force_after is not None else None
force_deadline = time.monotonic() + force_after
force_sent = False
while time.monotonic() < deadline:
pid = get_running_pid()
if pid is None:
return True # Process exited cleanly.
return # Process exited cleanly.
if force_after is not None and not force_sent and time.monotonic() >= force_deadline:
if not force_sent and time.monotonic() >= force_deadline:
# Grace period expired — force-kill the specific PID.
try:
terminate_pid(pid, force=True)
print(f"⚠ Gateway PID {pid} did not exit gracefully; sent SIGKILL")
except (ProcessLookupError, PermissionError, OSError):
return True # Already gone or we can't touch it.
return # Already gone or we can't touch it.
force_sent = True
time.sleep(0.3)
@@ -1361,30 +1249,15 @@ def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float | None = 5.
remaining_pid = get_running_pid()
if remaining_pid is not None:
print(f"⚠ Gateway PID {remaining_pid} still running after {timeout}s — restart may fail")
return False
return True
def launchd_restart():
label = get_launchd_label()
target = f"{_launchd_domain()}/{label}"
drain_timeout = _get_restart_drain_timeout()
from gateway.status import get_running_pid
# Use kickstart -k so launchd performs an atomic kill+restart.
# A two-step stop/start from inside the gateway's own process tree
# would kill the shell before the start command is reached.
try:
pid = get_running_pid()
if pid is not None and _request_gateway_self_restart(pid):
print("✓ Service restart requested")
return
if pid is not None:
try:
terminate_pid(pid, force=False)
except (ProcessLookupError, PermissionError, OSError):
pid = None
if pid is not None:
exited = _wait_for_gateway_exit(timeout=drain_timeout, force_after=None)
if not exited:
print(f"⚠ Gateway drain timed out after {drain_timeout:.0f}s — forcing launchd restart")
subprocess.run(["launchctl", "kickstart", "-k", target], check=True, timeout=90)
print("✓ Service restarted")
except subprocess.CalledProcessError as e:
@@ -1569,7 +1442,7 @@ _PLATFORMS = [
" Or via API: curl -X POST https://your-server/_matrix/client/v3/login \\",
" -d '{\"type\":\"m.login.password\",\"user\":\"@bot:server\",\"password\":\"...\"}'",
"4. Alternatively, provide user ID + password and Hermes will log in directly",
"5. For E2EE: set MATRIX_ENCRYPTION=true (requires pip install 'mautrix[encryption]')",
"5. For E2EE: set MATRIX_ENCRYPTION=true (requires pip install 'matrix-nio[e2e]')",
"6. To find your user ID: it's @username:your-server (shown in Element profile)",
],
"vars": [
@@ -1855,8 +1728,6 @@ def _runtime_health_lines() -> list[str]:
lines: list[str] = []
gateway_state = state.get("gateway_state")
exit_reason = state.get("exit_reason")
active_agents = state.get("active_agents")
restart_requested = state.get("restart_requested")
platforms = state.get("platforms", {}) or {}
for platform, pdata in platforms.items():
@@ -1866,10 +1737,6 @@ def _runtime_health_lines() -> list[str]:
if gateway_state == "startup_failed" and exit_reason:
lines.append(f"⚠ Last startup issue: {exit_reason}")
elif gateway_state == "draining":
action = "restart" if restart_requested else "shutdown"
count = int(active_agents or 0)
lines.append(f"⏳ Gateway draining for {action} ({count} active agent(s))")
elif gateway_state == "stopped" and exit_reason:
lines.append(f"⚠ Last shutdown reason: {exit_reason}")
@@ -2377,8 +2244,7 @@ def gateway_setup():
print()
if supports_systemd_services() or is_macos():
platform_name = "systemd" if supports_systemd_services() else "launchd"
wsl_note = " (note: services may not survive WSL restarts)" if is_wsl() else ""
if prompt_yes_no(f" Install the gateway as a {platform_name} service?{wsl_note} (runs in background, starts on boot)", True):
if prompt_yes_no(f" Install the gateway as a {platform_name} service? (runs in background, starts on boot)", True):
try:
installed_scope = None
did_install = False
@@ -2403,21 +2269,16 @@ def gateway_setup():
print_info(" You can install later: hermes gateway install")
if supports_systemd_services():
print_info(" Or as a boot-time service: sudo hermes gateway install --system")
print_info(" Or run in foreground: hermes gateway run")
elif is_wsl():
print_info(" WSL detected but systemd is not running.")
print_info(" Run in foreground: hermes gateway run")
print_info(" For persistence: tmux new -s hermes 'hermes gateway run'")
print_info(" To enable systemd: add systemd=true to /etc/wsl.conf, then 'wsl --shutdown'")
print_info(" Or run in foreground: hermes gateway")
else:
if is_termux():
from hermes_constants import display_hermes_home as _dhh
print_info(" Termux does not use systemd/launchd services.")
print_info(" Run in foreground: hermes gateway run")
print_info(f" Or start it manually in the background (best effort): nohup hermes gateway run >{_dhh()}/logs/gateway.log 2>&1 &")
print_info(" Run in foreground: hermes gateway")
print_info(f" Or start it manually in the background (best effort): nohup hermes gateway >{_dhh()}/logs/gateway.log 2>&1 &")
else:
print_info(" Service install not supported on this platform.")
print_info(" Run in foreground: hermes gateway run")
print_info(" Run in foreground: hermes gateway")
else:
print()
print_info("No platforms configured. Run 'hermes gateway setup' when ready.")
@@ -2458,23 +2319,9 @@ def gateway_command(args):
print("Run manually: hermes gateway")
sys.exit(1)
if supports_systemd_services():
if is_wsl():
print_warning("WSL detected — systemd services may not survive WSL restarts.")
print_info(" Consider running in foreground instead: hermes gateway run")
print_info(" Or use tmux/screen for persistence: tmux new -s hermes 'hermes gateway run'")
print()
systemd_install(force=force, system=system, run_as_user=run_as_user)
elif is_macos():
launchd_install(force)
elif is_wsl():
print("WSL detected but systemd is not running.")
print("Either enable systemd (add systemd=true to /etc/wsl.conf and restart WSL)")
print("or run the gateway in foreground mode:")
print()
print(" hermes gateway run # direct foreground")
print(" tmux new -s hermes 'hermes gateway run' # persistent via tmux")
print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background")
sys.exit(1)
else:
print("Service installation not supported on this platform.")
print("Run manually: hermes gateway run")
@@ -2507,16 +2354,6 @@ def gateway_command(args):
systemd_start(system=system)
elif is_macos():
launchd_start()
elif is_wsl():
print("WSL detected but systemd is not available.")
print("Run the gateway in foreground mode instead:")
print()
print(" hermes gateway run # direct foreground")
print(" tmux new -s hermes 'hermes gateway run' # persistent via tmux")
print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background")
print()
print("To enable systemd: add systemd=true to /etc/wsl.conf and run 'wsl --shutdown' from PowerShell.")
sys.exit(1)
else:
print("Not supported on this platform.")
sys.exit(1)
@@ -2651,10 +2488,6 @@ def gateway_command(args):
if is_termux():
print("Termux note:")
print(" Android may stop background jobs when Termux is suspended")
elif is_wsl():
print("WSL note:")
print(" The gateway is running in foreground/manual mode (recommended for WSL).")
print(" Use tmux or screen for persistence across terminal closes.")
else:
print("To install as a service:")
print(" hermes gateway install")
@@ -2669,12 +2502,9 @@ def gateway_command(args):
print(f" {line}")
print()
print("To start:")
print(" hermes gateway run # Run in foreground")
print(" hermes gateway # Run in foreground")
if is_termux():
print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # Best-effort background start")
elif is_wsl():
print(" tmux new -s hermes 'hermes gateway run' # persistent via tmux")
print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background")
print(" nohup hermes gateway > ~/.hermes/logs/gateway.log 2>&1 & # Best-effort background start")
else:
print(" hermes gateway install # Install as user service")
print(" sudo hermes gateway install --system # Install as boot-time system service")
+3 -39
View File
@@ -1080,42 +1080,6 @@ def select_provider_and_model(args=None):
elif selected_provider in ("gemini", "zai", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface"):
_model_flow_api_key_provider(config, selected_provider, current_model)
# ── Post-switch cleanup: clear stale OPENAI_BASE_URL ──────────────
# When the user switches to a named provider (anything except "custom"),
# a leftover OPENAI_BASE_URL in ~/.hermes/.env can poison auxiliary
# clients that use provider:auto. Clear it proactively. (#5161)
if selected_provider not in ("custom", "cancel", "remove-custom") \
and not selected_provider.startswith("custom:"):
_clear_stale_openai_base_url()
def _clear_stale_openai_base_url():
"""Remove OPENAI_BASE_URL from ~/.hermes/.env if the active provider is not 'custom'.
After a provider switch, a leftover OPENAI_BASE_URL causes auxiliary
clients (compression, vision, delegation) with provider:auto to route
requests to the old custom endpoint instead of the newly selected
provider. See issue #5161.
"""
from hermes_cli.config import get_env_value, save_env_value, load_config
cfg = load_config()
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
provider = (model_cfg.get("provider") or "").strip().lower()
else:
provider = ""
if provider == "custom" or not provider:
return # custom provider legitimately uses OPENAI_BASE_URL
stale_url = get_env_value("OPENAI_BASE_URL")
if stale_url:
save_env_value("OPENAI_BASE_URL", "")
print(f"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url[:40]}...)"
if len(stale_url) > 40
else f"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url})")
def _prompt_provider_choice(choices, *, default=0):
"""Show provider selection menu with curses arrow-key navigation.
@@ -4483,7 +4447,7 @@ For more help on a command:
gateway_subparsers = gateway_parser.add_subparsers(dest="gateway_command")
# gateway run (default)
gateway_run = gateway_subparsers.add_parser("run", help="Run gateway in foreground (recommended for WSL, Docker, Termux)")
gateway_run = gateway_subparsers.add_parser("run", help="Run gateway in foreground")
gateway_run.add_argument("-v", "--verbose", action="count", default=0,
help="Increase stderr log verbosity (-v=INFO, -vv=DEBUG)")
gateway_run.add_argument("-q", "--quiet", action="store_true",
@@ -4492,7 +4456,7 @@ For more help on a command:
help="Replace any existing gateway instance (useful for systemd)")
# gateway start
gateway_start = gateway_subparsers.add_parser("start", help="Start the installed systemd/launchd background service")
gateway_start = gateway_subparsers.add_parser("start", help="Start gateway service")
gateway_start.add_argument("--system", action="store_true", help="Target the Linux system-level gateway service")
# gateway stop
@@ -4510,7 +4474,7 @@ For more help on a command:
gateway_status.add_argument("--system", action="store_true", help="Target the Linux system-level gateway service")
# gateway install
gateway_install = gateway_subparsers.add_parser("install", help="Install gateway as a systemd/launchd background service")
gateway_install = gateway_subparsers.add_parser("install", help="Install gateway as service")
gateway_install.add_argument("--force", action="store_true", help="Force reinstall")
gateway_install.add_argument("--system", action="store_true", help="Install as a Linux system-level service (starts at boot)")
gateway_install.add_argument("--run-as-user", dest="run_as_user", help="User account the Linux system service should run as")
+12 -8
View File
@@ -87,8 +87,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"openai/gpt-5.4-nano",
],
"openai-codex": [
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.3-codex",
"gpt-5.2-codex",
"gpt-5.1-codex-mini",
@@ -159,16 +157,22 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"kimi-k2-0905-preview",
],
"minimax": [
"MiniMax-M2.7",
"MiniMax-M1",
"MiniMax-M1-40k",
"MiniMax-M1-80k",
"MiniMax-M1-128k",
"MiniMax-M1-256k",
"MiniMax-M2.5",
"MiniMax-M2.1",
"MiniMax-M2",
"MiniMax-M2.7",
],
"minimax-cn": [
"MiniMax-M2.7",
"MiniMax-M1",
"MiniMax-M1-40k",
"MiniMax-M1-80k",
"MiniMax-M1-128k",
"MiniMax-M1-256k",
"MiniMax-M2.5",
"MiniMax-M2.1",
"MiniMax-M2",
"MiniMax-M2.7",
],
"anthropic": [
"claude-opus-4-6",
-2
View File
@@ -143,7 +143,6 @@ def _tts_label(current_provider: str) -> str:
"openai": "OpenAI TTS",
"elevenlabs": "ElevenLabs",
"edge": "Edge TTS",
"mistral": "Mistral Voxtral TTS",
"neutts": "NeuTTS",
}
return mapping.get(current_provider or "edge", current_provider or "Edge TTS")
@@ -310,7 +309,6 @@ def get_nous_subscription_features(
tts_current_provider in {"edge", "neutts"}
or (tts_current_provider == "openai" and (managed_tts_available or direct_openai_tts))
or (tts_current_provider == "elevenlabs" and direct_elevenlabs)
or (tts_current_provider == "mistral" and bool(get_env_value("MISTRAL_API_KEY")))
)
tts_active = bool(tts_tool_enabled and tts_available)
+2 -39
View File
@@ -201,7 +201,8 @@ class PluginContext:
The *setup_fn* receives an argparse subparser and should add any
arguments/sub-subparsers. If *handler_fn* is provided it is set
as the default dispatch function via ``set_defaults(func=...)``."""
as the default dispatch function via ``set_defaults(func=...)``.
"""
self._manager._cli_commands[name] = {
"name": name,
"help": help,
@@ -212,38 +213,6 @@ class PluginContext:
}
logger.debug("Plugin %s registered CLI command: %s", self.manifest.name, name)
# -- context engine registration -----------------------------------------
def register_context_engine(self, engine) -> None:
"""Register a context engine to replace the built-in ContextCompressor.
Only one context engine plugin is allowed. If a second plugin tries
to register one, it is rejected with a warning.
The engine must be an instance of ``agent.context_engine.ContextEngine``.
"""
if self._manager._context_engine is not None:
logger.warning(
"Plugin '%s' tried to register a context engine, but one is "
"already registered. Only one context engine plugin is allowed.",
self.manifest.name,
)
return
# Defer the import to avoid circular deps at module level
from agent.context_engine import ContextEngine
if not isinstance(engine, ContextEngine):
logger.warning(
"Plugin '%s' tried to register a context engine that does not "
"inherit from ContextEngine. Ignoring.",
self.manifest.name,
)
return
self._manager._context_engine = engine
logger.info(
"Plugin '%s' registered context engine: %s",
self.manifest.name, engine.name,
)
# -- hook registration --------------------------------------------------
def register_hook(self, hook_name: str, callback: Callable) -> None:
@@ -276,7 +245,6 @@ class PluginManager:
self._hooks: Dict[str, List[Callable]] = {}
self._plugin_tool_names: Set[str] = set()
self._cli_commands: Dict[str, dict] = {}
self._context_engine = None # Set by a plugin via register_context_engine()
self._discovered: bool = False
self._cli_ref = None # Set by CLI after plugin discovery
@@ -598,11 +566,6 @@ def get_plugin_cli_commands() -> Dict[str, dict]:
return dict(get_plugin_manager()._cli_commands)
def get_plugin_context_engine():
"""Return the plugin-registered context engine, or None."""
return get_plugin_manager()._context_engine
def get_plugin_toolsets() -> List[tuple]:
"""Return plugin toolsets as ``(key, label, description)`` tuples.
+29 -467
View File
@@ -531,7 +531,7 @@ def cmd_disable(name: str) -> None:
disabled.add(name)
_save_disabled_set(disabled)
console.print(f"[yellow]\u2298[/yellow] Plugin [bold]{name}[/bold] disabled. Takes effect on next session.")
console.print(f"[yellow][/yellow] Plugin [bold]{name}[/bold] disabled. Takes effect on next session.")
def cmd_list() -> None:
@@ -594,152 +594,8 @@ def cmd_list() -> None:
console.print("[dim]Enable/disable:[/dim] hermes plugins enable/disable <name>")
# ---------------------------------------------------------------------------
# Provider plugin discovery helpers
# ---------------------------------------------------------------------------
def _discover_memory_providers() -> list[tuple[str, str]]:
"""Return [(name, description), ...] for available memory providers."""
try:
from plugins.memory import discover_memory_providers
return [(name, desc) for name, desc, _avail in discover_memory_providers()]
except Exception:
return []
def _discover_context_engines() -> list[tuple[str, str]]:
"""Return [(name, description), ...] for available context engines."""
try:
from plugins.context_engine import discover_context_engines
return [(name, desc) for name, desc, _avail in discover_context_engines()]
except Exception:
return []
def _get_current_memory_provider() -> str:
"""Return the current memory.provider from config (empty = built-in)."""
try:
from hermes_cli.config import load_config
config = load_config()
return config.get("memory", {}).get("provider", "") or ""
except Exception:
return ""
def _get_current_context_engine() -> str:
"""Return the current context.engine from config."""
try:
from hermes_cli.config import load_config
config = load_config()
return config.get("context", {}).get("engine", "compressor") or "compressor"
except Exception:
return "compressor"
def _save_memory_provider(name: str) -> None:
"""Persist memory.provider to config.yaml."""
from hermes_cli.config import load_config, save_config
config = load_config()
if "memory" not in config:
config["memory"] = {}
config["memory"]["provider"] = name
save_config(config)
def _save_context_engine(name: str) -> None:
"""Persist context.engine to config.yaml."""
from hermes_cli.config import load_config, save_config
config = load_config()
if "context" not in config:
config["context"] = {}
config["context"]["engine"] = name
save_config(config)
def _configure_memory_provider() -> bool:
"""Launch a radio picker for memory providers. Returns True if changed."""
from hermes_cli.curses_ui import curses_radiolist
current = _get_current_memory_provider()
providers = _discover_memory_providers()
# Build items: "built-in" first, then discovered providers
items = ["built-in (default)"]
names = [""] # empty string = built-in
selected = 0
for name, desc in providers:
names.append(name)
label = f"{name} \u2014 {desc}" if desc else name
items.append(label)
if name == current:
selected = len(items) - 1
# If current provider isn't in discovered list, add it
if current and current not in names:
names.append(current)
items.append(f"{current} (not found)")
selected = len(items) - 1
choice = curses_radiolist(
title="Memory Provider (select one)",
items=items,
selected=selected,
)
new_provider = names[choice]
if new_provider != current:
_save_memory_provider(new_provider)
return True
return False
def _configure_context_engine() -> bool:
"""Launch a radio picker for context engines. Returns True if changed."""
from hermes_cli.curses_ui import curses_radiolist
current = _get_current_context_engine()
engines = _discover_context_engines()
# Build items: "compressor" first (built-in), then discovered engines
items = ["compressor (default)"]
names = ["compressor"]
selected = 0
for name, desc in engines:
names.append(name)
label = f"{name} \u2014 {desc}" if desc else name
items.append(label)
if name == current:
selected = len(items) - 1
# If current engine isn't in discovered list and isn't compressor, add it
if current != "compressor" and current not in names:
names.append(current)
items.append(f"{current} (not found)")
selected = len(items) - 1
choice = curses_radiolist(
title="Context Engine (select one)",
items=items,
selected=selected,
)
new_engine = names[choice]
if new_engine != current:
_save_context_engine(new_engine)
return True
return False
# ---------------------------------------------------------------------------
# Composite plugins UI
# ---------------------------------------------------------------------------
def cmd_toggle() -> None:
"""Interactive composite UI — general plugins + provider plugin categories."""
"""Interactive curses checklist to enable/disable installed plugins."""
from rich.console import Console
try:
@@ -750,13 +606,18 @@ def cmd_toggle() -> None:
console = Console()
plugins_dir = _plugins_dir()
# -- General plugins discovery --
dirs = sorted(d for d in plugins_dir.iterdir() if d.is_dir())
if not dirs:
console.print("[dim]No plugins installed.[/dim]")
console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
return
disabled = _get_disabled_set()
plugin_names = []
plugin_labels = []
plugin_selected = set()
# Build items list: "name — description" for display
names = []
labels = []
selected = set()
for i, d in enumerate(dirs):
manifest_file = d / "plugin.yaml"
@@ -772,335 +633,36 @@ def cmd_toggle() -> None:
except Exception:
pass
plugin_names.append(name)
label = f"{name} \u2014 {description}" if description else name
plugin_labels.append(label)
names.append(name)
label = f"{name} {description}" if description else name
labels.append(label)
if name not in disabled and d.name not in disabled:
plugin_selected.add(i)
selected.add(i)
# -- Provider categories --
current_memory = _get_current_memory_provider() or "built-in"
current_context = _get_current_context_engine()
categories = [
("Memory Provider", current_memory, _configure_memory_provider),
("Context Engine", current_context, _configure_context_engine),
]
from hermes_cli.curses_ui import curses_checklist
has_plugins = bool(plugin_names)
has_categories = bool(categories)
result = curses_checklist(
title="Plugins — toggle enabled/disabled",
items=labels,
selected=selected,
)
if not has_plugins and not has_categories:
console.print("[dim]No plugins installed and no provider categories available.[/dim]")
console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
return
# Non-TTY fallback
if not sys.stdin.isatty():
console.print("[dim]Interactive mode requires a terminal.[/dim]")
return
# Launch the composite curses UI
try:
import curses
_run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
disabled, categories, console)
except ImportError:
_run_composite_fallback(plugin_names, plugin_labels, plugin_selected,
disabled, categories, console)
def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
disabled, categories, console):
"""Custom curses screen with checkboxes + category action rows."""
from hermes_cli.curses_ui import flush_stdin
chosen = set(plugin_selected)
n_plugins = len(plugin_names)
# Total rows: plugins + separator + categories
# separator is not navigable
n_categories = len(categories)
total_items = n_plugins + n_categories # navigable items
result_holder = {"plugins_changed": False, "providers_changed": False}
def _draw(stdscr):
curses.curs_set(0)
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
curses.init_pair(3, curses.COLOR_CYAN, -1)
curses.init_pair(4, 8, -1) # dim gray
cursor = 0
scroll_offset = 0
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
# Header
try:
hattr = curses.A_BOLD
if curses.has_colors():
hattr |= curses.color_pair(2)
stdscr.addnstr(0, 0, "Plugins", max_x - 1, hattr)
stdscr.addnstr(
1, 0,
" \u2191\u2193 navigate SPACE toggle ENTER configure/confirm ESC done",
max_x - 1, curses.A_DIM,
)
except curses.error:
pass
# Build display rows
# Row layout:
# [plugins section header] (not navigable, skipped in scroll math)
# plugin checkboxes (navigable, indices 0..n_plugins-1)
# [separator] (not navigable)
# [categories section header] (not navigable)
# category action rows (navigable, indices n_plugins..total_items-1)
visible_rows = max_y - 4
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
scroll_offset = cursor - visible_rows + 1
y = 3 # start drawing after header
# Determine which items are visible based on scroll
# We need to map logical cursor positions to screen rows
# accounting for non-navigable separator/headers
draw_row = 0 # tracks navigable item index
# --- General Plugins section ---
if n_plugins > 0:
# Section header
if y < max_y - 1:
try:
sattr = curses.A_BOLD
if curses.has_colors():
sattr |= curses.color_pair(2)
stdscr.addnstr(y, 0, " General Plugins", max_x - 1, sattr)
except curses.error:
pass
y += 1
for i in range(n_plugins):
if y >= max_y - 1:
break
check = "\u2713" if i in chosen else " "
arrow = "\u2192" if i == cursor else " "
line = f" {arrow} [{check}] {plugin_labels[i]}"
attr = curses.A_NORMAL
if i == cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass
y += 1
# --- Separator ---
if y < max_y - 1:
y += 1 # blank line
# --- Provider Plugins section ---
if n_categories > 0 and y < max_y - 1:
try:
sattr = curses.A_BOLD
if curses.has_colors():
sattr |= curses.color_pair(2)
stdscr.addnstr(y, 0, " Provider Plugins", max_x - 1, sattr)
except curses.error:
pass
y += 1
for ci, (cat_name, cat_current, _cat_fn) in enumerate(categories):
if y >= max_y - 1:
break
cat_idx = n_plugins + ci
arrow = "\u2192" if cat_idx == cursor else " "
line = f" {arrow} {cat_name:<24} \u25b8 {cat_current}"
attr = curses.A_NORMAL
if cat_idx == cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(3)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass
y += 1
stdscr.refresh()
key = stdscr.getch()
if key in (curses.KEY_UP, ord("k")):
if total_items > 0:
cursor = (cursor - 1) % total_items
elif key in (curses.KEY_DOWN, ord("j")):
if total_items > 0:
cursor = (cursor + 1) % total_items
elif key == ord(" "):
if cursor < n_plugins:
# Toggle general plugin
chosen.symmetric_difference_update({cursor})
else:
# Provider category — launch sub-screen
ci = cursor - n_plugins
if 0 <= ci < n_categories:
curses.endwin()
_cat_name, _cat_cur, cat_fn = categories[ci]
changed = cat_fn()
if changed:
result_holder["providers_changed"] = True
# Refresh current values
categories[ci] = (
_cat_name,
_get_current_memory_provider() or "built-in" if ci == 0
else _get_current_context_engine(),
cat_fn,
)
# Re-enter curses
stdscr = curses.initscr()
curses.noecho()
curses.cbreak()
stdscr.keypad(True)
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
curses.init_pair(3, curses.COLOR_CYAN, -1)
curses.init_pair(4, 8, -1)
curses.curs_set(0)
elif key in (curses.KEY_ENTER, 10, 13):
if cursor < n_plugins:
# ENTER on a plugin checkbox — confirm and exit
result_holder["plugins_changed"] = True
return
else:
# ENTER on a category — same as SPACE, launch sub-screen
ci = cursor - n_plugins
if 0 <= ci < n_categories:
curses.endwin()
_cat_name, _cat_cur, cat_fn = categories[ci]
changed = cat_fn()
if changed:
result_holder["providers_changed"] = True
categories[ci] = (
_cat_name,
_get_current_memory_provider() or "built-in" if ci == 0
else _get_current_context_engine(),
cat_fn,
)
stdscr = curses.initscr()
curses.noecho()
curses.cbreak()
stdscr.keypad(True)
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
curses.init_pair(3, curses.COLOR_CYAN, -1)
curses.init_pair(4, 8, -1)
curses.curs_set(0)
elif key in (27, ord("q")):
# Save plugin changes on exit
result_holder["plugins_changed"] = True
return
curses.wrapper(_draw)
flush_stdin()
# Persist general plugin changes
# Compute new disabled set from deselected items
new_disabled = set()
for i, name in enumerate(plugin_names):
if i not in chosen:
for i, name in enumerate(names):
if i not in result:
new_disabled.add(name)
if new_disabled != disabled:
_save_disabled_set(new_disabled)
enabled_count = len(plugin_names) - len(new_disabled)
enabled_count = len(names) - len(new_disabled)
console.print(
f"\n[green]\u2713[/green] General plugins: {enabled_count} enabled, "
f"{len(new_disabled)} disabled."
f"\n[green]✓[/green] {enabled_count} enabled, {len(new_disabled)} disabled. "
f"Takes effect on next session."
)
elif n_plugins > 0:
console.print("\n[dim]General plugins unchanged.[/dim]")
if result_holder["providers_changed"]:
new_memory = _get_current_memory_provider() or "built-in"
new_context = _get_current_context_engine()
console.print(
f"[green]\u2713[/green] Memory provider: [bold]{new_memory}[/bold] "
f"Context engine: [bold]{new_context}[/bold]"
)
if n_plugins > 0 or result_holder["providers_changed"]:
console.print("[dim]Changes take effect on next session.[/dim]")
console.print()
def _run_composite_fallback(plugin_names, plugin_labels, plugin_selected,
disabled, categories, console):
"""Text-based fallback for the composite plugins UI."""
from hermes_cli.colors import Colors, color
print(color("\n Plugins", Colors.YELLOW))
# General plugins
if plugin_names:
chosen = set(plugin_selected)
print(color("\n General Plugins", Colors.YELLOW))
print(color(" Toggle by number, Enter to confirm.\n", Colors.DIM))
while True:
for i, label in enumerate(plugin_labels):
marker = color("[\u2713]", Colors.GREEN) if i in chosen else "[ ]"
print(f" {marker} {i + 1:>2}. {label}")
print()
try:
val = input(color(" Toggle # (or Enter to confirm): ", Colors.DIM)).strip()
if not val:
break
idx = int(val) - 1
if 0 <= idx < len(plugin_names):
chosen.symmetric_difference_update({idx})
except (ValueError, KeyboardInterrupt, EOFError):
return
print()
new_disabled = set()
for i, name in enumerate(plugin_names):
if i not in chosen:
new_disabled.add(name)
if new_disabled != disabled:
_save_disabled_set(new_disabled)
# Provider categories
if categories:
print(color("\n Provider Plugins", Colors.YELLOW))
for ci, (cat_name, cat_current, cat_fn) in enumerate(categories):
print(f" {ci + 1}. {cat_name} [{cat_current}]")
print()
try:
val = input(color(" Configure # (or Enter to skip): ", Colors.DIM)).strip()
if val:
ci = int(val) - 1
if 0 <= ci < len(categories):
categories[ci][2]() # call the configure function
except (ValueError, KeyboardInterrupt, EOFError):
pass
print()
else:
console.print("\n[dim]No changes.[/dim]")
def plugins_command(args) -> None:
+2 -2
View File
@@ -88,11 +88,11 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
base_url_env_var="KIMI_BASE_URL",
),
"minimax": HermesOverlay(
transport="anthropic_messages",
transport="openai_chat",
base_url_env_var="MINIMAX_BASE_URL",
),
"minimax-cn": HermesOverlay(
transport="anthropic_messages",
transport="openai_chat",
base_url_env_var="MINIMAX_CN_BASE_URL",
),
"deepseek": HermesOverlay(
+15 -45
View File
@@ -106,8 +106,8 @@ _DEFAULT_PROVIDER_MODELS = {
],
"zai": ["glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
"kimi-coding": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
"minimax": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
"minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
"minimax": ["MiniMax-M1", "MiniMax-M1-40k", "MiniMax-M1-80k", "MiniMax-M1-128k", "MiniMax-M1-256k", "MiniMax-M2.5", "MiniMax-M2.7"],
"minimax-cn": ["MiniMax-M1", "MiniMax-M1-40k", "MiniMax-M1-80k", "MiniMax-M1-128k", "MiniMax-M1-256k", "MiniMax-M2.5", "MiniMax-M2.7"],
"ai-gateway": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5", "google/gemini-3-flash"],
"kilocode": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview"],
"opencode-zen": ["gpt-5.4", "gpt-5.3-codex", "claude-sonnet-4-6", "gemini-3-flash", "glm-5", "kimi-k2.5", "minimax-m2.7"],
@@ -557,8 +557,6 @@ def _print_setup_summary(config: dict, hermes_home):
tool_status.append(("Text-to-Speech (OpenAI)", True, None))
elif tts_provider == "minimax" and get_env_value("MINIMAX_API_KEY"):
tool_status.append(("Text-to-Speech (MiniMax)", True, None))
elif tts_provider == "mistral" and get_env_value("MISTRAL_API_KEY"):
tool_status.append(("Text-to-Speech (Mistral Voxtral)", True, None))
elif tts_provider == "neutts":
try:
import importlib.util
@@ -1046,7 +1044,6 @@ def _setup_tts_provider(config: dict):
"elevenlabs": "ElevenLabs",
"openai": "OpenAI TTS",
"minimax": "MiniMax TTS",
"mistral": "Mistral Voxtral TTS",
"neutts": "NeuTTS",
}
current_label = provider_labels.get(current_provider, current_provider)
@@ -1067,11 +1064,10 @@ def _setup_tts_provider(config: dict):
"ElevenLabs (premium quality, needs API key)",
"OpenAI TTS (good quality, needs API key)",
"MiniMax TTS (high quality with voice cloning, needs API key)",
"Mistral Voxtral TTS (multilingual, native Opus, needs API key)",
"NeuTTS (local on-device, free, ~300MB model download)",
]
)
providers.extend(["edge", "elevenlabs", "openai", "minimax", "mistral", "neutts"])
providers.extend(["edge", "elevenlabs", "openai", "minimax", "neutts"])
choices.append(f"Keep current ({current_label})")
keep_current_idx = len(choices) - 1
idx = prompt_choice("Select TTS provider:", choices, keep_current_idx)
@@ -1149,18 +1145,6 @@ def _setup_tts_provider(config: dict):
print_warning("No API key provided. Falling back to Edge TTS.")
selected = "edge"
elif selected == "mistral":
existing = get_env_value("MISTRAL_API_KEY")
if not existing:
print()
api_key = prompt("Mistral API key for TTS", password=True)
if api_key:
save_env_value("MISTRAL_API_KEY", api_key)
print_success("Mistral TTS API key saved")
else:
print_warning("No API key provided. Falling back to Edge TTS.")
selected = "edge"
# Save the selection
if "tts" not in config:
config["tts"] = {}
@@ -1941,9 +1925,9 @@ def _setup_matrix():
save_env_value("MATRIX_ENCRYPTION", "true")
print_success("E2EE enabled")
matrix_pkg = "mautrix[encryption]" if want_e2ee else "mautrix"
matrix_pkg = "matrix-nio[e2e]" if want_e2ee else "matrix-nio"
try:
__import__("mautrix")
__import__("nio")
except ImportError:
print_info(f"Installing {matrix_pkg}...")
import subprocess
@@ -2938,33 +2922,19 @@ def run_setup_wizard(args):
_offer_launch_chat()
def _resolve_hermes_chat_argv() -> Optional[list[str]]:
"""Resolve argv for launching ``hermes chat`` in a fresh process."""
hermes_bin = shutil.which("hermes")
if hermes_bin:
return [hermes_bin, "chat"]
try:
if importlib.util.find_spec("hermes_cli") is not None:
return [sys.executable, "-m", "hermes_cli.main", "chat"]
except Exception:
pass
return None
def _offer_launch_chat():
"""Prompt the user to jump straight into chat after setup."""
print()
if not prompt_yes_no("Launch hermes chat now?", True):
return
chat_argv = _resolve_hermes_chat_argv()
if not chat_argv:
print_info("Could not relaunch Hermes automatically. Run 'hermes chat' manually.")
return
os.execvp(chat_argv[0], chat_argv)
if prompt_yes_no("Launch hermes chat now?", True):
from hermes_cli.main import cmd_chat
from types import SimpleNamespace
cmd_chat(SimpleNamespace(
query=None, resume=None, continue_last=None, model=None,
provider=None, effort=None, skin=None, oneshot=False,
quiet=False, verbose=False, toolsets=None, skills=None,
yolo=False, source=None, worktree=False, checkpoints=False,
pass_session_id=False, max_turns=None,
))
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
+1 -13
View File
@@ -181,14 +181,6 @@ TOOL_CATEGORIES = {
],
"tts_provider": "elevenlabs",
},
{
"name": "Mistral (Voxtral TTS)",
"tag": "Multilingual, native Opus, needs MISTRAL_API_KEY",
"env_vars": [
{"key": "MISTRAL_API_KEY", "prompt": "Mistral API key", "url": "https://console.mistral.ai/"},
],
"tts_provider": "mistral",
},
],
},
"web": {
@@ -509,10 +501,6 @@ def _get_platform_tools(
default_ts = PLATFORMS[platform]["default_toolset"]
toolset_names = [default_ts]
# YAML may parse bare numeric names (e.g. ``12306:``) as int.
# Normalise to str so downstream sorted() never mixes types.
toolset_names = [str(ts) for ts in toolset_names]
configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}
# If the saved list contains any configurable keys directly, the user
@@ -571,7 +559,7 @@ def _get_platform_tools(
# Special sentinel: "no_mcp" in the toolset list disables all MCP servers.
mcp_servers = config.get("mcp_servers") or {}
enabled_mcp_servers = {
str(name)
name
for name, server_cfg in mcp_servers.items()
if isinstance(server_cfg, dict)
and _parse_enabled_flag(server_cfg.get("enabled", True), default=True)
-21
View File
@@ -168,27 +168,6 @@ def is_termux() -> bool:
return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix)
_wsl_detected: bool | None = None
def is_wsl() -> bool:
"""Return True when running inside WSL (Windows Subsystem for Linux).
Checks ``/proc/version`` for the ``microsoft`` marker that both WSL1
and WSL2 inject. Result is cached for the process lifetime.
Import-safe no heavy deps.
"""
global _wsl_detected
if _wsl_detected is not None:
return _wsl_detected
try:
with open("/proc/version", "r") as f:
_wsl_detected = "microsoft" in f.read().lower()
except Exception:
_wsl_detected = False
return _wsl_detected
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
@@ -617,19 +617,6 @@ class Migrator:
candidate = self.source_root / rel
if candidate.exists():
return candidate
# OpenClaw renamed workspace/ to workspace-main/ (and workspace-{agentId}
# for multi-agent). Try the new path as a fallback.
if rel.startswith("workspace/"):
suffix = rel[len("workspace/"):]
for variant in ("workspace-main", "workspace-assistant"):
alt = self.source_root / variant / suffix
if alt.exists():
return alt
elif rel.startswith("workspace.default/"):
suffix = rel[len("workspace.default/"):]
alt = self.source_root / "workspace-main" / suffix
if alt.exists():
return alt
return None
def resolve_skill_destination(self, destination: Path) -> Path:
-219
View File
@@ -1,219 +0,0 @@
"""Context engine plugin discovery.
Scans ``plugins/context_engine/<name>/`` directories for context engine
plugins. Each subdirectory must contain ``__init__.py`` with a class
implementing the ContextEngine ABC.
Context engines are separate from the general plugin system they live
in the repo and are always available without user installation. Only ONE
can be active at a time, selected via ``context.engine`` in config.yaml.
The default engine is ``"compressor"`` (the built-in ContextCompressor).
Usage:
from plugins.context_engine import discover_context_engines, load_context_engine
available = discover_context_engines() # [(name, desc, available), ...]
engine = load_context_engine("lcm") # ContextEngine instance
"""
from __future__ import annotations
import importlib
import importlib.util
import logging
import sys
from pathlib import Path
from typing import List, Optional, Tuple
logger = logging.getLogger(__name__)
_CONTEXT_ENGINE_PLUGINS_DIR = Path(__file__).parent
def discover_context_engines() -> List[Tuple[str, str, bool]]:
"""Scan plugins/context_engine/ for available engines.
Returns list of (name, description, is_available) tuples.
Does NOT import the engines just reads plugin.yaml for metadata
and does a lightweight availability check.
"""
results = []
if not _CONTEXT_ENGINE_PLUGINS_DIR.is_dir():
return results
for child in sorted(_CONTEXT_ENGINE_PLUGINS_DIR.iterdir()):
if not child.is_dir() or child.name.startswith(("_", ".")):
continue
init_file = child / "__init__.py"
if not init_file.exists():
continue
# Read description from plugin.yaml if available
desc = ""
yaml_file = child / "plugin.yaml"
if yaml_file.exists():
try:
import yaml
with open(yaml_file) as f:
meta = yaml.safe_load(f) or {}
desc = meta.get("description", "")
except Exception:
pass
# Quick availability check — try loading and calling is_available()
available = True
try:
engine = _load_engine_from_dir(child)
if engine is None:
available = False
elif hasattr(engine, "is_available"):
available = engine.is_available()
except Exception:
available = False
results.append((child.name, desc, available))
return results
def load_context_engine(name: str) -> Optional["ContextEngine"]:
"""Load and return a ContextEngine instance by name.
Returns None if the engine is not found or fails to load.
"""
engine_dir = _CONTEXT_ENGINE_PLUGINS_DIR / name
if not engine_dir.is_dir():
logger.debug("Context engine '%s' not found in %s", name, _CONTEXT_ENGINE_PLUGINS_DIR)
return None
try:
engine = _load_engine_from_dir(engine_dir)
if engine:
return engine
logger.warning("Context engine '%s' loaded but no engine instance found", name)
return None
except Exception as e:
logger.warning("Failed to load context engine '%s': %s", name, e)
return None
def _load_engine_from_dir(engine_dir: Path) -> Optional["ContextEngine"]:
"""Import an engine module and extract the ContextEngine instance.
The module must have either:
- A register(ctx) function (plugin-style) we simulate a ctx
- A top-level class that extends ContextEngine we instantiate it
"""
name = engine_dir.name
module_name = f"plugins.context_engine.{name}"
init_file = engine_dir / "__init__.py"
if not init_file.exists():
return None
# Check if already loaded
if module_name in sys.modules:
mod = sys.modules[module_name]
else:
# Handle relative imports within the plugin
# First ensure the parent packages are registered
for parent in ("plugins", "plugins.context_engine"):
if parent not in sys.modules:
parent_path = Path(__file__).parent
if parent == "plugins":
parent_path = parent_path.parent
parent_init = parent_path / "__init__.py"
if parent_init.exists():
spec = importlib.util.spec_from_file_location(
parent, str(parent_init),
submodule_search_locations=[str(parent_path)]
)
if spec:
parent_mod = importlib.util.module_from_spec(spec)
sys.modules[parent] = parent_mod
try:
spec.loader.exec_module(parent_mod)
except Exception:
pass
# Now load the engine module
spec = importlib.util.spec_from_file_location(
module_name, str(init_file),
submodule_search_locations=[str(engine_dir)]
)
if not spec:
return None
mod = importlib.util.module_from_spec(spec)
sys.modules[module_name] = mod
# Register submodules so relative imports work
for sub_file in engine_dir.glob("*.py"):
if sub_file.name == "__init__.py":
continue
sub_name = sub_file.stem
full_sub_name = f"{module_name}.{sub_name}"
if full_sub_name not in sys.modules:
sub_spec = importlib.util.spec_from_file_location(
full_sub_name, str(sub_file)
)
if sub_spec:
sub_mod = importlib.util.module_from_spec(sub_spec)
sys.modules[full_sub_name] = sub_mod
try:
sub_spec.loader.exec_module(sub_mod)
except Exception as e:
logger.debug("Failed to load submodule %s: %s", full_sub_name, e)
try:
spec.loader.exec_module(mod)
except Exception as e:
logger.debug("Failed to exec_module %s: %s", module_name, e)
sys.modules.pop(module_name, None)
return None
# Try register(ctx) pattern first (how plugins are written)
if hasattr(mod, "register"):
collector = _EngineCollector()
try:
mod.register(collector)
if collector.engine:
return collector.engine
except Exception as e:
logger.debug("register() failed for %s: %s", name, e)
# Fallback: find a ContextEngine subclass and instantiate it
from agent.context_engine import ContextEngine
for attr_name in dir(mod):
attr = getattr(mod, attr_name, None)
if (isinstance(attr, type) and issubclass(attr, ContextEngine)
and attr is not ContextEngine):
try:
return attr()
except Exception:
pass
return None
class _EngineCollector:
"""Fake plugin context that captures register_context_engine calls."""
def __init__(self):
self.engine = None
def register_context_engine(self, engine):
self.engine = engine
# No-op for other registration methods
def register_tool(self, *args, **kwargs):
pass
def register_hook(self, *args, **kwargs):
pass
def register_cli_command(self, *args, **kwargs):
pass
def register_memory_provider(self, *args, **kwargs):
pass
+2 -10
View File
@@ -218,11 +218,9 @@ class HonchoMemoryProvider(MemoryProvider):
return
# Override peer_name with gateway user_id for per-user memory scoping.
# Only when no explicit peerName was configured — an explicit peerName
# means the user chose their identity; a raw user_id (e.g. Telegram
# chat ID) should not silently replace it.
# CLI sessions won't have user_id, so the config default is preserved.
_gw_user_id = kwargs.get("user_id")
if _gw_user_id and not cfg.peer_name:
if _gw_user_id:
cfg.peer_name = _gw_user_id
self._config = cfg
@@ -250,12 +248,6 @@ class HonchoMemoryProvider(MemoryProvider):
# ----- Port #1957: lazy session init for tools-only mode -----
if self._recall_mode == "tools":
if cfg.init_on_session_start:
# Eager init: create session now so sync_turn() works from turn 1.
# Does NOT enable auto-injection — prefetch() still returns empty.
logger.debug("Honcho tools-only mode — eager session init (initOnSessionStart=true)")
self._do_session_init(cfg, session_id, **kwargs)
return
# Defer actual session creation until first tool call
self._lazy_init_kwargs = kwargs
self._lazy_init_session_id = session_id
-10
View File
@@ -189,11 +189,6 @@ class HonchoClientConfig:
# "context" — auto-injected context only, Honcho tools removed
# "tools" — Honcho tools only, no auto-injected context
recall_mode: str = "hybrid"
# When True and recallMode is "tools", create the Honcho session eagerly
# during initialize() instead of deferring to the first tool call.
# This ensures sync_turn() can write from the very first turn.
# Does NOT enable automatic context injection — only changes init timing.
init_on_session_start: bool = False
# Observation mode: legacy string shorthand ("directional" or "unified").
# Kept for backward compat; granular per-peer booleans below are preferred.
observation_mode: str = "directional"
@@ -371,11 +366,6 @@ class HonchoClientConfig:
or raw.get("recallMode")
or "hybrid"
),
init_on_session_start=_resolve_bool(
host_block.get("initOnSessionStart"),
raw.get("initOnSessionStart"),
default=False,
),
# Migration guard: existing configs without an explicit
# observationMode keep the old "unified" default so users
# aren't silently switched to full bidirectional observation.
+6 -6
View File
@@ -16,7 +16,7 @@ dependencies = [
"anthropic>=0.39.0,<1",
"python-dotenv>=1.2.1,<2",
"fire>=0.7.1,<1",
"httpx[socks]>=0.28.1,<1",
"httpx>=0.28.1,<1",
"rich>=14.3.3,<15",
"tenacity>=9.1.4,<10",
"pyyaml>=6.0.2,<7",
@@ -43,7 +43,7 @@ dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "py
messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
cron = ["croniter>=6.0.0,<7"]
slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4"]
matrix = ["matrix-nio[e2e]>=0.24.0,<1", "Markdown>=3.6,<4"]
cli = ["simple-term-menu>=1.0,<2"]
tts-premium = ["elevenlabs>=1.0,<2"]
voice = [
@@ -88,10 +88,10 @@ all = [
"hermes-agent[modal]",
"hermes-agent[daytona]",
"hermes-agent[messaging]",
# matrix: python-olm (required by matrix-nio[e2e]) is upstream-broken on
# modern macOS (archived libolm, C++ errors with Clang 21+). On Linux the
# [matrix] extra's own marker pulls in the [e2e] variant automatically.
"hermes-agent[matrix]; sys_platform == 'linux'",
# matrix excluded: python-olm (required by matrix-nio[e2e]) is upstream-broken
# on modern macOS (archived libolm, C++ errors with Clang 21+). Including it
# here causes the entire [all] install to fail, dropping all other extras.
# Users who need Matrix can install manually: pip install 'hermes-agent[matrix]'
"hermes-agent[cron]",
"hermes-agent[cli]",
"hermes-agent[dev]",
+113 -351
View File
@@ -766,7 +766,7 @@ class AIAgent:
# conversation prefix. Uses system_and_3 strategy (4 breakpoints).
is_openrouter = self._is_openrouter_url()
is_claude = "claude" in self.model.lower()
is_native_anthropic = self.api_mode == "anthropic_messages" and self.provider == "anthropic"
is_native_anthropic = self.api_mode == "anthropic_messages"
self._use_prompt_caching = (is_openrouter and is_claude) or is_native_anthropic
self._cache_ttl = "5m" # Default 5-minute TTL (1.25x write cost)
@@ -1268,88 +1268,20 @@ class AIAgent:
pass
break
# Select context engine: config-driven (like memory providers).
# 1. Check config.yaml context.engine setting
# 2. Check plugins/context_engine/<name>/ directory (repo-shipped)
# 3. Check general plugin system (user-installed plugins)
# 4. Fall back to built-in ContextCompressor
_selected_engine = None
_engine_name = "compressor" # default
try:
_ctx_cfg = _agent_cfg.get("context", {}) if isinstance(_agent_cfg, dict) else {}
_engine_name = _ctx_cfg.get("engine", "compressor") or "compressor"
except Exception:
pass
if _engine_name != "compressor":
# Try loading from plugins/context_engine/<name>/
try:
from plugins.context_engine import load_context_engine
_selected_engine = load_context_engine(_engine_name)
except Exception as _ce_load_err:
logger.debug("Context engine load from plugins/context_engine/: %s", _ce_load_err)
# Try general plugin system as fallback
if _selected_engine is None:
try:
from hermes_cli.plugins import get_plugin_context_engine
_candidate = get_plugin_context_engine()
if _candidate and _candidate.name == _engine_name:
_selected_engine = _candidate
except Exception:
pass
if _selected_engine is None:
logger.warning(
"Context engine '%s' not found — falling back to built-in compressor",
_engine_name,
)
# else: config says "compressor" — use built-in, don't auto-activate plugins
if _selected_engine is not None:
self.context_compressor = _selected_engine
if not self.quiet_mode:
logger.info("Using context engine: %s", _selected_engine.name)
else:
self.context_compressor = ContextCompressor(
model=self.model,
threshold_percent=compression_threshold,
protect_first_n=3,
protect_last_n=compression_protect_last,
summary_target_ratio=compression_target_ratio,
summary_model_override=compression_summary_model,
quiet_mode=self.quiet_mode,
base_url=self.base_url,
api_key=getattr(self, "api_key", ""),
config_context_length=_config_context_length,
provider=self.provider,
)
self.context_compressor = ContextCompressor(
model=self.model,
threshold_percent=compression_threshold,
protect_first_n=3,
protect_last_n=compression_protect_last,
summary_target_ratio=compression_target_ratio,
summary_model_override=compression_summary_model,
quiet_mode=self.quiet_mode,
base_url=self.base_url,
api_key=getattr(self, "api_key", ""),
config_context_length=_config_context_length,
provider=self.provider,
)
self.compression_enabled = compression_enabled
# Inject context engine tool schemas (e.g. lcm_grep, lcm_describe, lcm_expand)
self._context_engine_tool_names: set = set()
if hasattr(self, "context_compressor") and self.context_compressor and self.tools is not None:
for _schema in self.context_compressor.get_tool_schemas():
_wrapped = {"type": "function", "function": _schema}
self.tools.append(_wrapped)
_tname = _schema.get("name", "")
if _tname:
self.valid_tool_names.add(_tname)
self._context_engine_tool_names.add(_tname)
# Notify context engine of session start
if hasattr(self, "context_compressor") and self.context_compressor:
try:
self.context_compressor.on_session_start(
self.session_id,
hermes_home=str(get_hermes_home()),
platform=self.platform or "cli",
model=self.model,
context_length=getattr(self.context_compressor, "context_length", 0),
)
except Exception as _ce_err:
logger.debug("Context engine on_session_start: %s", _ce_err)
self._subdirectory_hints = SubdirectoryHintTracker(
working_dir=os.getenv("TERMINAL_CWD") or None,
)
@@ -1415,13 +1347,11 @@ class AIAgent:
"api_key": getattr(self, "api_key", ""),
"client_kwargs": dict(self._client_kwargs),
"use_prompt_caching": self._use_prompt_caching,
# Context engine state that _try_activate_fallback() overwrites.
# Use getattr for model/base_url/api_key/provider since plugin
# engines may not have these (they're ContextCompressor-specific).
"compressor_model": getattr(_cc, "model", self.model),
"compressor_base_url": getattr(_cc, "base_url", self.base_url),
# Compressor state that _try_activate_fallback() overwrites
"compressor_model": _cc.model,
"compressor_base_url": _cc.base_url,
"compressor_api_key": getattr(_cc, "api_key", ""),
"compressor_provider": getattr(_cc, "provider", self.provider),
"compressor_provider": _cc.provider,
"compressor_context_length": _cc.context_length,
"compressor_threshold_tokens": _cc.threshold_tokens,
}
@@ -1467,9 +1397,15 @@ class AIAgent:
# Turn counter (added after reset_session_state was first written — #2635)
self._user_turn_count = 0
# Context engine reset (works for both built-in compressor and plugins)
# Context compressor internal counters (if present)
if hasattr(self, "context_compressor") and self.context_compressor:
self.context_compressor.on_session_reset()
self.context_compressor.last_prompt_tokens = 0
self.context_compressor.last_completion_tokens = 0
self.context_compressor.compression_count = 0
self.context_compressor._context_probed = False
self.context_compressor._context_probe_persistable = False
# Iterative summary from previous session must not bleed into new one (#2635)
self.context_compressor._previous_summary = None
def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mode=''):
"""Switch the model/provider in-place for a live agent.
@@ -1510,11 +1446,7 @@ class AIAgent:
resolve_anthropic_token,
_is_oauth_token,
)
# Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic.
# Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own
# API key — falling back would send Anthropic credentials to third-party endpoints.
_is_native_anthropic = new_provider == "anthropic"
effective_key = (api_key or self.api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or self.api_key or "")
effective_key = api_key or self.api_key or resolve_anthropic_token() or ""
self.api_key = effective_key
self._anthropic_api_key = effective_key
self._anthropic_base_url = base_url or getattr(self, "_anthropic_base_url", None)
@@ -1538,7 +1470,7 @@ class AIAgent:
)
# ── Re-evaluate prompt caching ──
is_native_anthropic = api_mode == "anthropic_messages" and new_provider == "anthropic"
is_native_anthropic = api_mode == "anthropic_messages"
self._use_prompt_caching = (
("openrouter" in (self.base_url or "").lower() and "claude" in new_model.lower())
or is_native_anthropic
@@ -1554,12 +1486,13 @@ class AIAgent:
provider=self.provider,
config_context_length=getattr(self, "_config_context_length", None),
)
self.context_compressor.update_model(
model=self.model,
context_length=new_context_length,
base_url=self.base_url,
api_key=getattr(self, "api_key", ""),
provider=self.provider,
self.context_compressor.model = self.model
self.context_compressor.base_url = self.base_url
self.context_compressor.api_key = self.api_key
self.context_compressor.provider = self.provider
self.context_compressor.context_length = new_context_length
self.context_compressor.threshold_tokens = int(
new_context_length * self.context_compressor.threshold_percent
)
# ── Invalidate cached system prompt so it rebuilds next turn ──
@@ -1575,10 +1508,10 @@ class AIAgent:
"api_key": getattr(self, "api_key", ""),
"client_kwargs": dict(self._client_kwargs),
"use_prompt_caching": self._use_prompt_caching,
"compressor_model": getattr(_cc, "model", self.model) if _cc else self.model,
"compressor_base_url": getattr(_cc, "base_url", self.base_url) if _cc else self.base_url,
"compressor_model": _cc.model if _cc else self.model,
"compressor_base_url": _cc.base_url if _cc else self.base_url,
"compressor_api_key": getattr(_cc, "api_key", "") if _cc else "",
"compressor_provider": getattr(_cc, "provider", self.provider) if _cc else self.provider,
"compressor_provider": _cc.provider if _cc else self.provider,
"compressor_context_length": _cc.context_length if _cc else 0,
"compressor_threshold_tokens": _cc.threshold_tokens if _cc else 0,
}
@@ -2044,14 +1977,19 @@ class AIAgent:
except Exception as e:
logger.debug("Background memory/skill review failed: %s", 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).
# Explicitly close the OpenAI/httpx client so GC doesn't
# try to clean it up on a dead asyncio event loop (which
# produces "Event loop is closed" errors in the terminal).
if review_agent is not None:
try:
review_agent.close()
except Exception:
pass
client = getattr(review_agent, "client", None)
if client is not None:
try:
review_agent._close_openai_client(
client, reason="bg_review_done", shared=True
)
review_agent.client = None
except Exception:
pass
t = threading.Thread(target=_run_review, daemon=True, name="bg-review")
t.start()
@@ -2775,11 +2713,10 @@ class AIAgent:
}
def shutdown_memory_provider(self, messages: list = None) -> None:
"""Shut down the memory provider and context engine — call at actual session boundaries.
"""Shut down the memory provider — call at actual session boundaries.
This calls on_session_end() then shutdown_all() on the memory
manager, and on_session_end() on the context engine.
NOT called per-turn only at CLI exit, /reset, gateway
manager. NOT called per-turn only at CLI exit, /reset, gateway
session expiry, etc.
"""
if self._memory_manager:
@@ -2791,74 +2728,7 @@ class AIAgent:
self._memory_manager.shutdown_all()
except Exception:
pass
# Notify context engine of session end (flush DAG, close DBs, etc.)
if hasattr(self, "context_compressor") and self.context_compressor:
try:
self.context_compressor.on_session_end(
self.session_id or "",
messages or [],
)
except Exception:
pass
def close(self) -> None:
"""Release all resources held by this agent instance.
Cleans up subprocess resources that would otherwise become orphans:
- Background processes tracked in ProcessRegistry
- Terminal sandbox environments
- Browser daemon sessions
- Active child agents (subagent delegation)
- OpenAI/httpx client connections
Safe to call multiple times (idempotent). Each cleanup step is
independently guarded so a failure in one does not prevent the rest.
"""
task_id = getattr(self, "session_id", None) or ""
# 1. Kill background processes for this task
try:
from tools.process_registry import process_registry
process_registry.kill_all(task_id=task_id)
except Exception:
pass
# 2. Clean terminal sandbox environments
try:
from tools.terminal_tool import cleanup_vm
cleanup_vm(task_id)
except Exception:
pass
# 3. Clean browser daemon sessions
try:
from tools.browser_tool import cleanup_browser
cleanup_browser(task_id)
except Exception:
pass
# 4. Close active child agents
try:
with self._active_children_lock:
children = list(self._active_children)
self._active_children.clear()
for child in children:
try:
child.close()
except Exception:
pass
except Exception:
pass
# 5. Close the OpenAI/httpx client
try:
client = getattr(self, "client", None)
if client is not None:
self._close_openai_client(client, reason="agent_close", shared=True)
self.client = None
except Exception:
pass
def _hydrate_todo_store(self, history: List[Dict[str, Any]]) -> None:
"""
Recover todo state from conversation history.
@@ -4429,7 +4299,7 @@ class AIAgent:
self._anthropic_api_key = runtime_key
self._anthropic_base_url = runtime_base
self._anthropic_client = build_anthropic_client(runtime_key, runtime_base)
self._is_anthropic_oauth = _is_oauth_token(runtime_key)
self._is_anthropic_oauth = _is_oauth_token(runtime_key) if self.provider == "anthropic" else False
self.api_key = runtime_key
self.base_url = runtime_base
return
@@ -5301,7 +5171,7 @@ class AIAgent:
}
# Re-evaluate prompt caching for the new provider/model
is_native_anthropic = fb_api_mode == "anthropic_messages" and fb_provider == "anthropic"
is_native_anthropic = fb_api_mode == "anthropic_messages"
self._use_prompt_caching = (
("openrouter" in fb_base_url.lower() and "claude" in fb_model.lower())
or is_native_anthropic
@@ -5317,12 +5187,13 @@ class AIAgent:
self.model, base_url=self.base_url,
api_key=self.api_key, provider=self.provider,
)
self.context_compressor.update_model(
model=self.model,
context_length=fb_context_length,
base_url=self.base_url,
api_key=getattr(self, "api_key", ""),
provider=self.provider,
self.context_compressor.model = self.model
self.context_compressor.base_url = self.base_url
self.context_compressor.api_key = self.api_key
self.context_compressor.provider = self.provider
self.context_compressor.context_length = fb_context_length
self.context_compressor.threshold_tokens = int(
fb_context_length * self.context_compressor.threshold_percent
)
self._emit_status(
@@ -5382,15 +5253,14 @@ class AIAgent:
shared=True,
)
# ── Restore context engine state ──
# ── Restore context compressor state ──
cc = self.context_compressor
cc.update_model(
model=rt["compressor_model"],
context_length=rt["compressor_context_length"],
base_url=rt["compressor_base_url"],
api_key=rt["compressor_api_key"],
provider=rt["compressor_provider"],
)
cc.model = rt["compressor_model"]
cc.base_url = rt["compressor_base_url"]
cc.api_key = rt["compressor_api_key"]
cc.provider = rt["compressor_provider"]
cc.context_length = rt["compressor_context_length"]
cc.threshold_tokens = rt["compressor_threshold_tokens"]
# ── Reset fallback chain for the new turn ──
self._fallback_activated = False
@@ -5637,12 +5507,11 @@ class AIAgent:
def _anthropic_preserve_dots(self) -> bool:
"""True when using an anthropic-compatible endpoint that preserves dots in model names.
Alibaba/DashScope keeps dots (e.g. qwen3.5-plus).
MiniMax keeps dots (e.g. MiniMax-M2.7).
OpenCode Go keeps dots (e.g. minimax-m2.7)."""
if (getattr(self, "provider", "") or "").lower() in {"alibaba", "minimax", "minimax-cn", "opencode-go"}:
if (getattr(self, "provider", "") or "").lower() in {"alibaba", "opencode-go"}:
return True
base = (getattr(self, "base_url", "") or "").lower()
return "dashscope" in base or "aliyuncs" in base or "minimax" in base or "opencode.ai/zen/go" in base
return "dashscope" in base or "aliyuncs" in base or "opencode.ai/zen/go" in base
def _is_qwen_portal(self) -> bool:
"""Return True when the base URL targets Qwen Portal."""
@@ -5888,16 +5757,8 @@ class AIAgent:
api_kwargs["tools"] = self.tools
if self.max_tokens is not None:
api_kwargs.update(self._max_tokens_param(self.max_tokens))
elif self._is_qwen_portal():
# Qwen Portal defaults to a very low max_tokens when omitted.
# Reasoning models (qwen3-coder-plus) exhaust that budget on
# thinking tokens alone, causing the portal to return
# finish_reason="stop" with truncated output — the agent sees
# this as an intentional stop and exits the loop. Send 65536
# (the documented max output for qwen3-coder models) so the
# model has adequate output budget for tool calls.
api_kwargs.update(self._max_tokens_param(65536))
if not self._is_qwen_portal():
api_kwargs.update(self._max_tokens_param(self.max_tokens))
elif (self._is_openrouter_url() or "nousresearch" in self._base_url_lower) and "claude" in (self.model or "").lower():
# OpenRouter and Nous Portal translate requests to Anthropic's
# Messages API, which requires max_tokens as a mandatory field.
@@ -6964,29 +6825,6 @@ class AIAgent:
spinner.stop(cute_msg)
elif self._should_emit_quiet_tool_messages():
self._vprint(f" {cute_msg}")
elif self._context_engine_tool_names and function_name in self._context_engine_tool_names:
# Context engine tools (lcm_grep, lcm_describe, lcm_expand, etc.)
spinner = None
if self.quiet_mode and not self.tool_progress_callback:
face = random.choice(KawaiiSpinner.KAWAII_WAITING)
emoji = _get_tool_emoji(function_name)
preview = _build_tool_preview(function_name, function_args) or function_name
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=self._print_fn)
spinner.start()
_ce_result = None
try:
function_result = self.context_compressor.handle_tool_call(function_name, function_args, messages=messages)
_ce_result = function_result
except Exception as tool_error:
function_result = json.dumps({"error": f"Context engine tool '{function_name}' failed: {tool_error}"})
logger.error("context_engine.handle_tool_call raised for %s: %s", function_name, tool_error, exc_info=True)
finally:
tool_duration = time.time() - tool_start_time
cute_msg = _get_cute_tool_message_impl(function_name, function_args, tool_duration, result=_ce_result)
if spinner:
spinner.stop(cute_msg)
elif self.quiet_mode:
self._vprint(f" {cute_msg}")
elif self._memory_manager and self._memory_manager.has_tool(function_name):
# Memory provider tools (hindsight_retain, honcho_search, etc.)
# These are not in the tool registry — route through MemoryManager.
@@ -7642,7 +7480,6 @@ class AIAgent:
is_first_turn=(not bool(conversation_history)),
model=self.model,
platform=getattr(self, "platform", None) or "",
sender_id=getattr(self, "_user_id", None) or "",
)
_ctx_parts: list[str] = []
for r in _pre_results:
@@ -7871,7 +7708,6 @@ class AIAgent:
finish_reason = "stop"
response = None # Guard against UnboundLocalError if all retries fail
api_kwargs = None # Guard against UnboundLocalError in except handler
while retry_count < max_retries:
try:
@@ -8302,7 +8138,7 @@ class AIAgent:
# Cache discovered context length after successful call.
# Only persist limits confirmed by the provider (parsed
# from the error message), not guessed probe tiers.
if getattr(self.context_compressor, "_context_probed", False):
if self.context_compressor._context_probed:
ctx = self.context_compressor.context_length
if getattr(self.context_compressor, "_context_probe_persistable", False):
save_context_length(self.model, self.base_url, ctx)
@@ -8641,22 +8477,16 @@ class AIAgent:
compressor = self.context_compressor
old_ctx = compressor.context_length
if old_ctx > _reduced_ctx:
compressor.update_model(
model=self.model,
context_length=_reduced_ctx,
base_url=self.base_url,
api_key=getattr(self, "api_key", ""),
provider=self.provider,
compressor.context_length = _reduced_ctx
compressor.threshold_tokens = int(
_reduced_ctx * compressor.threshold_percent
)
# Context probing flags — only set on built-in
# compressor (plugin engines manage their own).
if hasattr(compressor, "_context_probed"):
compressor._context_probed = True
# Don't persist — this is a subscription-tier
# limitation, not a model capability. If the
# user later enables extra usage the 1M limit
# should come back automatically.
compressor._context_probe_persistable = False
compressor._context_probed = True
# Don't persist — this is a subscription-tier
# limitation, not a model capability. If the user
# later enables extra usage the 1M limit should
# come back automatically.
compressor._context_probe_persistable = False
self._vprint(
f"{self.log_prefix}⚠️ Anthropic long-context tier "
f"requires extra usage — reducing context: "
@@ -8820,25 +8650,17 @@ class AIAgent:
new_ctx = get_next_probe_tier(old_ctx)
if new_ctx and new_ctx < old_ctx:
compressor.update_model(
model=self.model,
context_length=new_ctx,
base_url=self.base_url,
api_key=getattr(self, "api_key", ""),
provider=self.provider,
compressor.context_length = new_ctx
compressor.threshold_tokens = int(new_ctx * compressor.threshold_percent)
compressor._context_probed = True
# Only persist limits parsed from the provider's
# error message (a real number). Guessed fallback
# tiers from get_next_probe_tier() should stay
# in-memory only — persisting them pollutes the
# cache with wrong values.
compressor._context_probe_persistable = bool(
parsed_limit and parsed_limit == new_ctx
)
# Context probing flags — only set on built-in
# compressor (plugin engines manage their own).
if hasattr(compressor, "_context_probed"):
compressor._context_probed = True
# Only persist limits parsed from the provider's
# error message (a real number). Guessed fallback
# tiers from get_next_probe_tier() should stay
# in-memory only — persisting them pollutes the
# cache with wrong values.
compressor._context_probe_persistable = bool(
parsed_limit and parsed_limit == new_ctx
)
self._vprint(f"{self.log_prefix}⚠️ Context length exceeded — stepping down: {old_ctx:,}{new_ctx:,} tokens", force=True)
else:
self._vprint(f"{self.log_prefix}⚠️ Context length exceeded at minimum tier — attempting compression...", force=True)
@@ -8920,10 +8742,9 @@ class AIAgent:
if self._try_activate_fallback():
retry_count = 0
continue
if api_kwargs is not None:
self._dump_api_request_debug(
api_kwargs, reason="non_retryable_client_error", error=api_error,
)
self._dump_api_request_debug(
api_kwargs, reason="non_retryable_client_error", error=api_error,
)
self._emit_status(
f"❌ Non-retryable error (HTTP {status_code}): "
f"{self._summarize_api_error(api_error)}"
@@ -9026,10 +8847,9 @@ class AIAgent:
self.log_prefix, max_retries, _final_summary,
_provider, _model, len(api_messages), f"{approx_tokens:,}",
)
if api_kwargs is not None:
self._dump_api_request_debug(
api_kwargs, reason="max_retries_exhausted", error=api_error,
)
self._dump_api_request_debug(
api_kwargs, reason="max_retries_exhausted", error=api_error,
)
self._persist_session(messages, conversation_history)
_final_response = f"API call failed after {max_retries} retries: {_final_summary}"
if _is_stream_drop:
@@ -9583,8 +9403,7 @@ class AIAgent:
fallback = getattr(self, '_last_content_with_tools', None)
if fallback:
_turn_exit_reason = "fallback_prior_turn_content"
logger.info("Empty follow-up after tool calls — using prior turn content as final response")
self._emit_status("↻ Empty response after tool calls — using earlier content as final answer")
logger.debug("Empty follow-up after tool calls — using prior turn content as final response")
self._last_content_with_tools = None
self._empty_content_retries = 0
for i in range(len(messages) - 1, -1, -1):
@@ -9615,13 +9434,9 @@ class AIAgent:
)
if _has_structured and self._thinking_prefill_retries < 2:
self._thinking_prefill_retries += 1
logger.info(
"Thinking-only response (no visible content)"
"prefilling to continue (%d/2)",
self._thinking_prefill_retries,
)
self._emit_status(
f"↻ Thinking-only response — prefilling to continue "
self._vprint(
f"{self.log_prefix}Thinking-only response — "
f"prefilling to continue "
f"({self._thinking_prefill_retries}/2)"
)
interim_msg = self._build_assistant_message(
@@ -9637,57 +9452,23 @@ class AIAgent:
# Model returned nothing — no content, no
# structured reasoning, no tool calls. Common
# with open models (transient provider issues,
# rate limits, sampling flukes). Retry up to 3
# times before attempting fallback. Skip when
# rate limits, sampling flukes). Silently retry
# up to 3 times before giving up. Skip when
# content has inline <think> tags (model chose
# to reason, just no visible text).
_truly_empty = not final_response.strip()
if _truly_empty and not _has_structured and self._empty_content_retries < 3:
self._empty_content_retries += 1
logger.warning(
"Empty response (no content or reasoning) "
"retry %d/3 (model=%s)",
self._empty_content_retries, self.model,
)
self._emit_status(
f"⚠️ Empty response from model — retrying "
f"({self._empty_content_retries}/3)"
self._vprint(
f"{self.log_prefix}Empty response (no content or reasoning) "
f"retrying ({self._empty_content_retries}/3)",
force=True,
)
continue
# ── Exhausted retries — try fallback provider ──
# Before giving up with "(empty)", attempt to
# switch to the next provider in the fallback
# chain. This covers the case where a model
# (e.g. GLM-4.5-Air) consistently returns empty
# due to context degradation or provider issues.
if _truly_empty and self._fallback_chain:
logger.warning(
"Empty response after %d retries — "
"attempting fallback (model=%s, provider=%s)",
self._empty_content_retries, self.model,
self.provider,
)
self._emit_status(
"⚠️ Model returning empty responses — "
"switching to fallback provider..."
)
if self._try_activate_fallback():
self._empty_content_retries = 0
self._emit_status(
f"↻ Switched to fallback: {self.model} "
f"({self.provider})"
)
logger.info(
"Fallback activated after empty responses: "
"now using %s on %s",
self.model, self.provider,
)
continue
# Exhausted retries and fallback chain (or no
# fallback configured). Fall through to the
# "(empty)" terminal.
# Exhausted prefill attempts, empty retries, or
# structured reasoning with no content —
# fall through to "(empty)" terminal.
_turn_exit_reason = "empty_response_exhausted"
reasoning_text = self._extract_reasoning(assistant_message)
assistant_msg = self._build_assistant_message(assistant_message, finish_reason)
@@ -9696,28 +9477,9 @@ class AIAgent:
if reasoning_text:
reasoning_preview = reasoning_text[:500] + "..." if len(reasoning_text) > 500 else reasoning_text
logger.warning(
"Reasoning-only response (no visible content) "
"after exhausting retries and fallback. "
"Reasoning: %s", reasoning_preview,
)
self._emit_status(
"⚠️ Model produced reasoning but no visible "
"response after all retries. Returning empty."
)
self._vprint(f"{self.log_prefix}️ Reasoning-only response (no visible content). Reasoning: {reasoning_preview}")
else:
logger.warning(
"Empty response (no content or reasoning) "
"after %d retries. No fallback available. "
"model=%s provider=%s",
self._empty_content_retries, self.model,
self.provider,
)
self._emit_status(
"❌ Model returned no content after all retries"
+ (" and fallback attempts." if self._fallback_chain else
". No fallback providers configured.")
)
self._vprint(f"{self.log_prefix}️ Empty response (no content or reasoning) after 3 retries.")
final_response = "(empty)"
break
-4
View File
@@ -249,12 +249,8 @@ def check_config(groq_key, eleven_key):
if stt_provider == "groq" and not groq_key:
warn("STT config says groq but GROQ_API_KEY is missing")
if stt_provider == "mistral" and not os.getenv("MISTRAL_API_KEY"):
warn("STT config says mistral but MISTRAL_API_KEY is missing")
if tts_provider == "elevenlabs" and not eleven_key:
warn("TTS config says elevenlabs but ELEVENLABS_API_KEY is missing")
if tts_provider == "mistral" and not os.getenv("MISTRAL_API_KEY"):
warn("TTS config says mistral but MISTRAL_API_KEY is missing")
except Exception as e:
warn("config.yaml", f"parse error: {e}")
else:
+7 -36
View File
@@ -1082,19 +1082,10 @@ install_node_deps() {
log_success "Node.js dependencies installed"
# Install Playwright browser + system dependencies.
# Playwright's --with-deps only supports apt-based systems natively.
# Playwright's install-deps only supports apt/dnf/zypper natively.
# For Arch/Manjaro we install the system libs via pacman first.
# Other systems must install Chromium dependencies manually.
log_info "Installing browser engine (Playwright Chromium)..."
case "$DISTRO" in
ubuntu|debian|raspbian|pop|linuxmint|elementary|zorin|kali|parrot)
log_info "Playwright may request sudo to install browser system dependencies (shared libraries)."
log_info "This is standard Playwright setup — Hermes itself does not require root access."
cd "$INSTALL_DIR" && npx playwright install --with-deps chromium 2>/dev/null || {
log_warn "Playwright browser installation failed — browser tools will not work."
log_warn "Try running manually: cd $INSTALL_DIR && npx playwright install --with-deps chromium"
}
;;
arch|manjaro)
if command -v pacman &> /dev/null; then
log_info "Arch/Manjaro detected — installing Chromium system dependencies via pacman..."
@@ -1109,35 +1100,15 @@ install_node_deps() {
log_warn " sudo pacman -S nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib"
fi
fi
cd "$INSTALL_DIR" && npx playwright install chromium 2>/dev/null || {
log_warn "Playwright browser installation failed — browser tools will not work."
}
;;
fedora|rhel|centos|rocky|alma)
log_warn "Playwright does not support automatic dependency installation on RPM-based systems."
log_info "Install Chromium system dependencies manually before using browser tools:"
log_info " sudo dnf install nss atk at-spi2-core cups-libs libdrm libxkbcommon mesa-libgbm pango cairo alsa-lib"
cd "$INSTALL_DIR" && npx playwright install chromium 2>/dev/null || {
log_warn "Playwright browser installation failed — install dependencies above and retry."
}
;;
opensuse*|sles)
log_warn "Playwright does not support automatic dependency installation on zypper-based systems."
log_info "Install Chromium system dependencies manually before using browser tools:"
log_info " sudo zypper install mozilla-nss libatk-1_0-0 at-spi2-core cups-libs libdrm2 libxkbcommon0 Mesa-libgbm1 pango cairo libasound2"
cd "$INSTALL_DIR" && npx playwright install chromium 2>/dev/null || {
log_warn "Playwright browser installation failed — install dependencies above and retry."
}
;;
*)
log_warn "Playwright does not support automatic dependency installation on $DISTRO."
log_info "Install Chromium/browser system dependencies for your distribution, then run:"
log_info " cd $INSTALL_DIR && npx playwright install chromium"
log_info "Browser tools will not work until dependencies are installed."
cd "$INSTALL_DIR" && npx playwright install chromium 2>/dev/null || true
;;
*)
log_info "Playwright may request sudo to install browser system dependencies (shared libraries)."
log_info "This is standard Playwright setup — Hermes itself does not require root access."
cd "$INSTALL_DIR" && npx playwright install --with-deps chromium 2>/dev/null || true
;;
esac
log_success "Browser engine setup complete"
log_success "Browser engine installed"
fi
# Install WhatsApp bridge dependencies
-27
View File
@@ -203,30 +203,3 @@ For segmented videos (quotes, scenes, chapters), render each as a separate clip
| `references/inputs.md` | Audio analysis (FFT, bands, beats), video sampling, image conversion, text/lyrics, TTS integration (ElevenLabs, voice assignment, audio mixing) |
| `references/optimization.md` | Hardware detection, quality profiles, vectorized patterns, parallel rendering, memory management, performance budgets |
| `references/troubleshooting.md` | NumPy broadcasting traps, blend mode pitfalls, multiprocessing/pickling, brightness diagnostics, ffmpeg issues, font problems, common mistakes |
---
## Creative Divergence (use only when user requests experimental/creative/unique output)
If the user asks for creative, experimental, surprising, or unconventional output, select the strategy that best fits and reason through its steps BEFORE generating code.
- **Forced Connections** — when the user wants cross-domain inspiration ("make it look organic," "industrial aesthetic")
- **Conceptual Blending** — when the user names two things to combine ("ocean meets music," "space + calligraphy")
- **Oblique Strategies** — when the user is maximally open ("surprise me," "something I've never seen")
### Forced Connections
1. Pick a domain unrelated to the visual goal (weather systems, microbiology, architecture, fluid dynamics, textile weaving)
2. List its core visual/structural elements (erosion → gradual reveal; mitosis → splitting duplication; weaving → interlocking patterns)
3. Map those elements onto ASCII characters and animation patterns
4. Synthesize — what does "erosion" or "crystallization" look like in a character grid?
### Conceptual Blending
1. Name two distinct visual/conceptual spaces (e.g., ocean waves + sheet music)
2. Map correspondences (crests = high notes, troughs = rests, foam = staccato)
3. Blend selectively — keep the most interesting mappings, discard forced ones
4. Develop emergent properties that exist only in the blend
### Oblique Strategies
1. Draw one: "Honor thy error as a hidden intention" / "Use an old idea" / "What would your closest friend do?" / "Emphasize the flaws" / "Turn it upside down" / "Only a part, not the whole" / "Reverse"
2. Interpret the directive against the current ASCII animation challenge
3. Apply the lateral insight to the visual design before writing code
-147
View File
@@ -1,147 +0,0 @@
---
name: ideation
title: Creative Ideation — Constraint-Driven Project Generation
description: "Generate project ideas through creative constraints. Use when the user says 'I want to build something', 'give me a project idea', 'I'm bored', 'what should I make', 'inspire me', or any variant of 'I have tools but no direction'. Works for code, art, hardware, writing, tools, and anything that can be made."
version: 1.0.0
author: SHL0MS
license: MIT
metadata:
hermes:
tags: [Creative, Ideation, Projects, Brainstorming, Inspiration]
category: creative
requires_toolsets: []
---
# Creative Ideation
Generate project ideas through creative constraints. Constraint + direction = creativity.
## How It Works
1. **Pick a constraint** from the library below — random, or matched to the user's domain/mood
2. **Interpret it broadly** — a coding prompt can become a hardware project, an art prompt can become a CLI tool
3. **Generate 3 concrete project ideas** that satisfy the constraint
4. **If they pick one, build it** — create the project, write the code, ship it
## The Rule
Every prompt is interpreted as broadly as possible. "Does this include X?" → Yes. The prompts provide direction and mild constraint. Without either, there is no creativity.
## Constraint Library
### For Developers
**Solve your own itch:**
Build the tool you wished existed this week. Under 50 lines. Ship it today.
**Automate the annoying thing:**
What's the most tedious part of your workflow? Script it away. Two hours to fix a problem that costs you five minutes a day.
**The CLI tool that should exist:**
Think of a command you've wished you could type. `git undo-that-thing-i-just-did`. `docker why-is-this-broken`. `npm explain-yourself`. Now build it.
**Nothing new except glue:**
Make something entirely from existing APIs, libraries, and datasets. The only original contribution is how you connect them.
**Frankenstein week:**
Take something that does X and make it do Y. A git repo that plays music. A Dockerfile that generates poetry. A cron job that sends compliments.
**Subtract:**
How much can you remove from a codebase before it breaks? Strip a tool to its minimum viable function. Delete until only the essence remains.
**High concept, low effort:**
A deep idea, lazily executed. The concept should be brilliant. The implementation should take an afternoon. If it takes longer, you're overthinking it.
### For Makers & Artists
**Blatantly copy something:**
Pick something you admire — a tool, an artwork, an interface. Recreate it from scratch. The learning is in the gap between your version and theirs.
**One million of something:**
One million is both a lot and not that much. One million pixels is a 1MB photo. One million API calls is a Tuesday. One million of anything becomes interesting at scale.
**Make something that dies:**
A website that loses a feature every day. A chatbot that forgets. A countdown to nothing. An exercise in rot, killing, or letting go.
**Do a lot of math:**
Generative geometry, shader golf, mathematical art, computational origami. Time to re-learn what an arcsin is.
### For Anyone
**Text is the universal interface:**
Build something where text is the only interface. No buttons, no graphics, just words in and words out. Text can go in and out of almost anything.
**Start at the punchline:**
Think of something that would be a funny sentence. Work backwards to make it real. "I taught my thermostat to gaslight me" → now build it.
**Hostile UI:**
Make something intentionally painful to use. A password field that requires 47 conditions. A form where every label lies. A CLI that judges your commands.
**Take two:**
Remember an old project. Do it again from scratch. No looking at the original. See what changed about how you think.
See `references/full-prompt-library.md` for 30+ additional constraints across communication, scale, philosophy, transformation, and more.
## Matching Constraints to Users
| User says | Pick from |
|-----------|-----------|
| "I want to build something" (no direction) | Random — any constraint |
| "I'm learning [language]" | Blatantly copy something, Automate the annoying thing |
| "I want something weird" | Hostile UI, Frankenstein week, Start at the punchline |
| "I want something useful" | Solve your own itch, The CLI that should exist, Automate the annoying thing |
| "I want something beautiful" | Do a lot of math, One million of something |
| "I'm burned out" | High concept low effort, Make something that dies |
| "Weekend project" | Nothing new except glue, Start at the punchline |
| "I want a challenge" | One million of something, Subtract, Take two |
## Output Format
```
## Constraint: [Name]
> [The constraint, one sentence]
### Ideas
1. **[One-line pitch]**
[2-3 sentences: what you'd build and why it's interesting]
⏱ [weekend / week / month] • 🔧 [stack]
2. **[One-line pitch]**
[2-3 sentences]
⏱ ... • 🔧 ...
3. **[One-line pitch]**
[2-3 sentences]
⏱ ... • 🔧 ...
```
## Example
```
## Constraint: The CLI tool that should exist
> Think of a command you've wished you could type. Now build it.
### Ideas
1. **`git whatsup` — show what happened while you were away**
Compares your last active commit to HEAD and summarizes what changed,
who committed, and what PRs merged. Like a morning standup from your repo.
⏱ weekend • 🔧 Python, GitPython, click
2. **`explain 503` — HTTP status codes for humans**
Pipe any status code or error message and get a plain-English explanation
with common causes and fixes. Pulls from a curated database, not an LLM.
⏱ weekend • 🔧 Rust or Go, static dataset
3. **`deps why <package>` — why is this in my dependency tree**
Traces a transitive dependency back to the direct dependency that pulled
it in. Answers "why do I have 47 copies of lodash" in one command.
⏱ weekend • 🔧 Node.js, npm/yarn lockfile parsing
```
After the user picks one, start building — create the project, write the code, iterate.
## Attribution
Constraint approach inspired by [wttdotm.com/prompts.html](https://wttdotm.com/prompts.html). Adapted and expanded for software development and general-purpose ideation.
@@ -1,110 +0,0 @@
# Full Prompt Library
Extended constraint library beyond the core set in SKILL.md. Load these when the user wants more variety or a specific category.
## Communication & Connection
**Create a means of distribution:**
The project works when you can use what you made to give something to somebody else.
**Make a way to communicate:**
The project works when you can hold a conversation with someone else using what you created. Not chat — something weirder.
**Write a love letter:**
To a person, a programming language, a game, a place, a tool. On paper, in code, in music, in light. Mail it.
**Mail chess / Asynchronous games:**
Something turn-based played with no time limit. No requirement to be there at the same time. The game happens in the gaps.
**Twitch plays X:**
A group of people share control over something. Collective input, emergent behavior.
## Screens & Interfaces
**Something for your desktop:**
You spend a lot of time there. Spruce it up. A custom clock, a pet that lives in your terminal, a wallpaper that changes based on your git activity.
**One screen, two screen, old screen, new screen:**
Take something you associate with one screen and put it on a very different one. DOOM on a smart fridge. A spreadsheet on a watch. A terminal in a painting.
**Make a mirror:**
Something that reflects the viewer back at themselves. A website that shows your browsing history. A CLI that prints your git sins.
## Philosophy & Concept
**Code as koan, koan as code:**
What is the sound of one hand clapping? A program that answers a question it wasn't asked. A function that returns before it's called.
**The useless tree:**
Make something useless. Deliberately, completely, beautifully useless. No utility. No purpose. No point. That's the point.
**Artificial stupidity:**
Make fun of AI by showcasing its faults. Mistrain it. Lie to it. Build the opposite of what AI is supposed to be good at.
**"I use technology in order to hate it properly":**
Make something inspired by the tension between loving and hating your tools.
**The more things change, the more they stay the same:**
Reflect on time, difference, and similarity.
## Transformation
**Translate:**
Take something meant for one audience and make it understandable by another. A research paper as a children's book. An API as a board game. A song as an architecture diagram.
**I mean, I GUESS you could store something that way:**
The project works when you can save and open something. Store data in DNS caches. Encode a novel in emoji. Write a file system on top of something that isn't a file system.
**I mean, I GUESS those could be pixels:**
The project works when you can display an image. Render anything visual in a medium that wasn't meant for rendering.
## Identity & Reflection
**Make a self-portrait:**
Be yourself? Be fake? Be real? In code, in data, in sound, in a directory structure.
**Make a pun:**
The stupider the better. Physical, digital, linguistic, visual. The project IS the joke.
**Doors, walls, borders, barriers, boundaries:**
Things that intermediate two places: opening, closing, permeating, excluding, combining.
## Scale & Repetition
**Lists!:**
Itemizations, taxonomies, exhaustive recountings, iterations. This one. A list of list of lists.
**Did you mean *recursion*?**
Did you mean recursion?
**Animals:**
Lions, and tigers, and bears. Crab logic gates. Fish plays the stock market.
**Cats:**
Where would the internet be without them.
## Starting Points
**An idea that comes from a book:**
Read something. Make something inspired by it.
**Go to a museum:**
Project ensues.
**NPC loot:**
What do you drop when you die? What do you take on your journey? Build the item.
**Mythological objects and entities:**
Pandora's box, the ocarina of time, the palantir. Build the artifact.
**69:**
Nice. Make something with the joke being the number 69.
**Office Space printer scene:**
Capture the same energy. Channel the catharsis of destroying the thing that frustrates you.
**Borges week:**
Something inspired by the Argentine. The library of babel. The map that is the territory.
**Lights!:**
LED throwies, light installations, illuminated anything. Make something that glows.
-23
View File
@@ -239,26 +239,3 @@ Always iterate at `-ql`. Only render `-qh` for final output.
| `references/paper-explainer.md` | Turning research papers into animations — workflow, templates, domain patterns |
| `references/decorations.md` | SurroundingRectangle, Brace, arrows, DashedLine, Angle, annotation lifecycle |
| `references/production-quality.md` | Pre-code, pre-render, post-render checklists, spatial layout, color, tempo |
---
## Creative Divergence (use only when user requests experimental/creative/unique output)
If the user asks for creative, experimental, or unconventional explanatory approaches, select a strategy and reason through it BEFORE designing the animation.
- **SCAMPER** — when the user wants a fresh take on a standard explanation
- **Assumption Reversal** — when the user wants to challenge how something is typically taught
### SCAMPER Transformation
Take a standard mathematical/technical visualization and transform it:
- **Substitute**: replace the standard visual metaphor (number line → winding path, matrix → city grid)
- **Combine**: merge two explanation approaches (algebraic + geometric simultaneously)
- **Reverse**: derive backward — start from the result and deconstruct to axioms
- **Modify**: exaggerate a parameter to show why it matters (10x the learning rate, 1000x the sample size)
- **Eliminate**: remove all notation — explain purely through animation and spatial relationships
### Assumption Reversal
1. List what's "standard" about how this topic is visualized (left-to-right, 2D, discrete steps, formal notation)
2. Pick the most fundamental assumption
3. Reverse it (right-to-left derivation, 3D embedding of a 2D concept, continuous morphing instead of steps, zero notation)
4. Explore what the reversal reveals that the standard approach hides
-34
View File
@@ -511,37 +511,3 @@ When building p5.js sketches:
| `references/export-pipeline.md` | `saveCanvas()`, `saveGif()`, `saveFrames()`, deterministic headless capture, ffmpeg frame-to-video, CCapture.js, SVG export, per-clip architecture, platform export (fxhash), video gotchas |
| `references/troubleshooting.md` | Performance profiling, per-pixel budgets, common mistakes, browser compatibility, WebGL debugging, font loading issues, pixel density traps, memory leaks, CORS |
| `templates/viewer.html` | Interactive viewer template: seed navigation (prev/next/random/jump), parameter sliders, download PNG, responsive canvas. Start from this for explorable generative art |
---
## Creative Divergence (use only when user requests experimental/creative/unique output)
If the user asks for creative, experimental, surprising, or unconventional output, select the strategy that best fits and reason through its steps BEFORE generating code.
- **Conceptual Blending** — when the user names two things to combine or wants hybrid aesthetics
- **SCAMPER** — when the user wants a twist on a known generative art pattern
- **Distance Association** — when the user gives a single concept and wants exploration ("make something about time")
### Conceptual Blending
1. Name two distinct visual systems (e.g., particle physics + handwriting)
2. Map correspondences (particles = ink drops, forces = pen pressure, fields = letterforms)
3. Blend selectively — keep mappings that produce interesting emergent visuals
4. Code the blend as a unified system, not two systems side-by-side
### SCAMPER Transformation
Take a known generative pattern (flow field, particle system, L-system, cellular automata) and systematically transform it:
- **Substitute**: replace circles with text characters, lines with gradients
- **Combine**: merge two patterns (flow field + voronoi)
- **Adapt**: apply a 2D pattern to a 3D projection
- **Modify**: exaggerate scale, warp the coordinate space
- **Purpose**: use a physics sim for typography, a sorting algorithm for color
- **Eliminate**: remove the grid, remove color, remove symmetry
- **Reverse**: run the simulation backward, invert the parameter space
### Distance Association
1. Anchor on the user's concept (e.g., "loneliness")
2. Generate associations at three distances:
- Close (obvious): empty room, single figure, silence
- Medium (interesting): one fish in a school swimming the wrong way, a phone with no notifications, the gap between subway cars
- Far (abstract): prime numbers, asymptotic curves, the color of 3am
3. Develop the medium-distance associations — they're specific enough to visualize but unexpected enough to be interesting
+2 -7
View File
@@ -39,13 +39,8 @@ class TestIsOAuthToken:
assert _is_oauth_token("sk-ant-api03-abcdef1234567890") is False
def test_managed_key(self):
# Managed keys from ~/.claude.json without a recognisable Anthropic
# prefix are not positively identified as OAuth. They enter the system
# via diagnostics-only read_claude_managed_key(), not via
# resolve_anthropic_token(), so they don't reach the OAuth gate in
# practice. Third-party provider keys (MiniMax, Alibaba) also lack
# the sk-ant- prefix and must NOT be treated as OAuth.
assert _is_oauth_token("ou1R1z-ft0A-bDeZ9wAA") is False
# Managed keys from ~/.claude.json are NOT regular API keys
assert _is_oauth_token("ou1R1z-ft0A-bDeZ9wAA") is True
def test_jwt_token(self):
# JWTs from OAuth flow
+8 -415
View File
@@ -1,10 +1,9 @@
"""Tests for agent.auxiliary_client resolution chain, provider overrides, and model overrides."""
import json
import logging
import os
from pathlib import Path
from unittest.mock import patch, MagicMock, AsyncMock
from unittest.mock import patch, MagicMock
import pytest
@@ -15,7 +14,6 @@ from agent.auxiliary_client import (
resolve_provider_client,
auxiliary_max_tokens_param,
call_llm,
async_call_llm,
_read_codex_access_token,
_get_auxiliary_provider,
_get_provider_chain,
@@ -660,19 +658,6 @@ class TestGetTextAuxiliaryClient:
assert client is None
assert model is None
def test_custom_endpoint_uses_codex_wrapper_when_runtime_requests_responses_api(self):
with patch("agent.auxiliary_client._resolve_custom_runtime",
return_value=("https://api.openai.com/v1", "sk-test", "codex_responses")), \
patch("agent.auxiliary_client._read_main_model", return_value="gpt-5.3-codex"), \
patch("agent.auxiliary_client.OpenAI") as mock_openai:
client, model = get_text_auxiliary_client()
from agent.auxiliary_client import CodexAuxiliaryClient
assert isinstance(client, CodexAuxiliaryClient)
assert model == "gpt-5.3-codex"
assert mock_openai.call_args.kwargs["base_url"] == "https://api.openai.com/v1"
assert mock_openai.call_args.kwargs["api_key"] == "sk-test"
class TestVisionClientFallback:
"""Vision client auto mode resolves known-good multimodal backends."""
@@ -758,69 +743,6 @@ class TestAuxiliaryPoolAwareness:
assert call_kwargs["base_url"] == "https://api.githubcopilot.com"
assert call_kwargs["default_headers"]["Editor-Version"]
def test_copilot_responses_api_model_wrapped_in_codex_client(self, monkeypatch):
"""Copilot GPT-5+ models (needing Responses API) are wrapped in CodexAuxiliaryClient."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
with (
patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={
"provider": "copilot",
"api_key": "test-token",
"base_url": "https://api.githubcopilot.com",
"source": "gh auth token",
},
),
patch("agent.auxiliary_client.OpenAI"),
):
client, model = resolve_provider_client("copilot", model="gpt-5.4-mini")
from agent.auxiliary_client import CodexAuxiliaryClient
assert isinstance(client, CodexAuxiliaryClient)
assert model == "gpt-5.4-mini"
def test_copilot_chat_completions_model_not_wrapped(self, monkeypatch):
"""Copilot models using Chat Completions are returned as plain OpenAI clients."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
with (
patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={
"provider": "copilot",
"api_key": "test-token",
"base_url": "https://api.githubcopilot.com",
"source": "gh auth token",
},
),
patch("agent.auxiliary_client.OpenAI") as mock_openai,
):
client, model = resolve_provider_client("copilot", model="gpt-4.1-mini")
from agent.auxiliary_client import CodexAuxiliaryClient
assert not isinstance(client, CodexAuxiliaryClient)
assert model == "gpt-4.1-mini"
# Should be the raw mock OpenAI client
assert client is mock_openai.return_value
def test_vision_auto_uses_active_provider_as_fallback(self, monkeypatch):
"""When no OpenRouter/Nous available, vision auto falls back to active provider."""
monkeypatch.setenv("ANTHROPIC_API_KEY", "***")
with (
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
patch("agent.auxiliary_client._read_main_provider", return_value="anthropic"),
patch("agent.auxiliary_client._read_main_model", return_value="claude-sonnet-4"),
patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()),
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="***"),
):
client, model = get_vision_auxiliary_client()
assert client is not None
assert client.__class__.__name__ == "AnthropicAuxiliaryClient"
def test_vision_auto_prefers_active_provider_over_openrouter(self, monkeypatch):
"""Active provider is tried before OpenRouter in vision auto."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
@@ -1124,8 +1046,8 @@ class TestCallLlmPaymentFallback:
exc.status_code = 402
return exc
def test_402_triggers_fallback_when_auto(self, monkeypatch):
"""When provider is auto and returns 402, call_llm tries the next one."""
def test_402_triggers_fallback(self, monkeypatch):
"""When the primary provider returns 402, call_llm tries the next one."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
primary_client = MagicMock()
@@ -1138,7 +1060,7 @@ class TestCallLlmPaymentFallback:
with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "google/gemini-3-flash-preview")), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("auto", "google/gemini-3-flash-preview", None, None, None)), \
return_value=("openrouter", "google/gemini-3-flash-preview", None, None)), \
patch("agent.auxiliary_client._try_payment_fallback",
return_value=(fallback_client, "gpt-5.2-codex", "openai-codex")) as mock_fb:
result = call_llm(
@@ -1147,62 +1069,13 @@ class TestCallLlmPaymentFallback:
)
assert result is fallback_response
mock_fb.assert_called_once_with("auto", "compression", reason="payment error")
mock_fb.assert_called_once_with("openrouter", "compression")
# Fallback call should use the fallback model
fb_kwargs = fallback_client.chat.completions.create.call_args.kwargs
assert fb_kwargs["model"] == "gpt-5.2-codex"
def test_402_no_fallback_when_explicit_provider(self, monkeypatch):
"""When provider is explicitly configured (not auto), 402 should NOT fallback (#7559)."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
primary_client = MagicMock()
primary_client.chat.completions.create.side_effect = self._make_402_error()
with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "local-model")), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("custom", "local-model", None, None, None)), \
patch("agent.auxiliary_client._try_payment_fallback") as mock_fb:
with pytest.raises(Exception, match="insufficient credits"):
call_llm(
task="compression",
messages=[{"role": "user", "content": "hello"}],
)
# Fallback should NOT be attempted when provider is explicit
mock_fb.assert_not_called()
def test_connection_error_triggers_fallback_when_auto(self, monkeypatch):
"""Connection errors also trigger fallback when provider is auto."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
primary_client = MagicMock()
conn_err = Exception("Connection refused")
conn_err.status_code = None
primary_client.chat.completions.create.side_effect = conn_err
fallback_client = MagicMock()
fallback_response = MagicMock()
fallback_client.chat.completions.create.return_value = fallback_response
with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "model")), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("auto", "model", None, None, None)), \
patch("agent.auxiliary_client._is_connection_error", return_value=True), \
patch("agent.auxiliary_client._try_payment_fallback",
return_value=(fallback_client, "fb-model", "nous")) as mock_fb:
result = call_llm(
task="compression",
messages=[{"role": "user", "content": "hello"}],
)
assert result is fallback_response
mock_fb.assert_called_once_with("auto", "compression", reason="connection error")
def test_non_payment_error_not_caught(self, monkeypatch):
"""Non-payment/non-connection errors (500) should NOT trigger fallback."""
"""Non-payment errors (500, connection, etc.) should NOT trigger fallback."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
primary_client = MagicMock()
@@ -1213,7 +1086,7 @@ class TestCallLlmPaymentFallback:
with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "google/gemini-3-flash-preview")), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("auto", "google/gemini-3-flash-preview", None, None, None)):
return_value=("openrouter", "google/gemini-3-flash-preview", None, None)):
with pytest.raises(Exception, match="Internal Server Error"):
call_llm(
task="compression",
@@ -1230,7 +1103,7 @@ class TestCallLlmPaymentFallback:
with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "google/gemini-3-flash-preview")), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("auto", "google/gemini-3-flash-preview", None, None, None)), \
return_value=("openrouter", "google/gemini-3-flash-preview", None, None)), \
patch("agent.auxiliary_client._try_payment_fallback",
return_value=(None, None, "")):
with pytest.raises(Exception, match="insufficient credits"):
@@ -1280,283 +1153,3 @@ def test_resolve_api_key_provider_skips_unconfigured_anthropic(monkeypatch):
assert "anthropic" not in called, \
"_try_anthropic() should not be called when anthropic is not explicitly configured"
# ---------------------------------------------------------------------------
# model="default" elimination (#7512)
# ---------------------------------------------------------------------------
class TestModelDefaultElimination:
"""_resolve_api_key_provider must skip providers without known aux models."""
def test_unknown_provider_skipped(self, monkeypatch):
"""Providers not in _API_KEY_PROVIDER_AUX_MODELS are skipped, not sent model='default'."""
from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
# Verify our known providers have entries
assert "gemini" in _API_KEY_PROVIDER_AUX_MODELS
assert "kimi-coding" in _API_KEY_PROVIDER_AUX_MODELS
# A random provider_id not in the dict should return None
assert _API_KEY_PROVIDER_AUX_MODELS.get("totally-unknown-provider") is None
def test_known_provider_gets_real_model(self):
"""Known providers get a real model name, not 'default'."""
from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
for provider_id, model in _API_KEY_PROVIDER_AUX_MODELS.items():
assert model != "default", f"{provider_id} should not map to 'default'"
assert isinstance(model, str) and model.strip(), \
f"{provider_id} should have a non-empty model string"
# ---------------------------------------------------------------------------
# _try_payment_fallback reason parameter (#7512 bug 3)
# ---------------------------------------------------------------------------
class TestTryPaymentFallbackReason:
"""_try_payment_fallback uses the reason parameter in log messages."""
def test_reason_parameter_passed_through(self, monkeypatch):
"""The reason= parameter is accepted without error."""
from agent.auxiliary_client import _try_payment_fallback
# Mock the provider chain to return nothing
monkeypatch.setattr(
"agent.auxiliary_client._get_provider_chain",
lambda: [],
)
monkeypatch.setattr(
"agent.auxiliary_client._read_main_provider",
lambda: "",
)
client, model, label = _try_payment_fallback(
"openrouter", task="compression", reason="connection error"
)
assert client is None
assert label == ""
# ---------------------------------------------------------------------------
# _is_connection_error coverage
# ---------------------------------------------------------------------------
class TestIsConnectionError:
"""Tests for _is_connection_error detection."""
def test_connection_refused(self):
from agent.auxiliary_client import _is_connection_error
err = Exception("Connection refused")
assert _is_connection_error(err) is True
def test_timeout(self):
from agent.auxiliary_client import _is_connection_error
err = Exception("Request timed out.")
assert _is_connection_error(err) is True
def test_dns_failure(self):
from agent.auxiliary_client import _is_connection_error
err = Exception("Name or service not known")
assert _is_connection_error(err) is True
def test_normal_api_error_not_connection(self):
from agent.auxiliary_client import _is_connection_error
err = Exception("Bad Request: invalid model")
err.status_code = 400
assert _is_connection_error(err) is False
def test_500_not_connection(self):
from agent.auxiliary_client import _is_connection_error
err = Exception("Internal Server Error")
err.status_code = 500
assert _is_connection_error(err) is False
# ---------------------------------------------------------------------------
# async_call_llm payment / connection fallback (#7512 bug 2)
# ---------------------------------------------------------------------------
class TestAsyncCallLlmFallback:
"""async_call_llm mirrors call_llm fallback behavior."""
def _make_402_error(self, msg="Payment Required: insufficient credits"):
exc = Exception(msg)
exc.status_code = 402
return exc
@pytest.mark.asyncio
async def test_402_triggers_async_fallback_when_auto(self, monkeypatch):
"""When provider is auto and returns 402, async_call_llm tries fallback."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
primary_client = MagicMock()
primary_client.chat.completions.create = AsyncMock(
side_effect=self._make_402_error())
# Fallback client (sync) returned by _try_payment_fallback
fb_sync_client = MagicMock()
fb_async_client = MagicMock()
fb_response = MagicMock()
fb_async_client.chat.completions.create = AsyncMock(return_value=fb_response)
with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "google/gemini-3-flash-preview")), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("auto", "google/gemini-3-flash-preview", None, None, None)), \
patch("agent.auxiliary_client._try_payment_fallback",
return_value=(fb_sync_client, "gpt-5.2-codex", "openai-codex")) as mock_fb, \
patch("agent.auxiliary_client._to_async_client",
return_value=(fb_async_client, "gpt-5.2-codex")):
result = await async_call_llm(
task="compression",
messages=[{"role": "user", "content": "hello"}],
)
assert result is fb_response
mock_fb.assert_called_once_with("auto", "compression", reason="payment error")
@pytest.mark.asyncio
async def test_402_no_async_fallback_when_explicit(self, monkeypatch):
"""When provider is explicit, 402 should NOT trigger async fallback."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
primary_client = MagicMock()
primary_client.chat.completions.create = AsyncMock(
side_effect=self._make_402_error())
with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "local-model")), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("custom", "local-model", None, None, None)), \
patch("agent.auxiliary_client._try_payment_fallback") as mock_fb:
with pytest.raises(Exception, match="insufficient credits"):
await async_call_llm(
task="compression",
messages=[{"role": "user", "content": "hello"}],
)
mock_fb.assert_not_called()
@pytest.mark.asyncio
async def test_connection_error_triggers_async_fallback(self, monkeypatch):
"""Connection errors trigger async fallback when provider is auto."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
primary_client = MagicMock()
conn_err = Exception("Connection refused")
conn_err.status_code = None
primary_client.chat.completions.create = AsyncMock(side_effect=conn_err)
fb_sync_client = MagicMock()
fb_async_client = MagicMock()
fb_response = MagicMock()
fb_async_client.chat.completions.create = AsyncMock(return_value=fb_response)
with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "model")), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("auto", "model", None, None, None)), \
patch("agent.auxiliary_client._is_connection_error", return_value=True), \
patch("agent.auxiliary_client._try_payment_fallback",
return_value=(fb_sync_client, "fb-model", "nous")) as mock_fb, \
patch("agent.auxiliary_client._to_async_client",
return_value=(fb_async_client, "fb-model")):
result = await async_call_llm(
task="compression",
messages=[{"role": "user", "content": "hello"}],
)
assert result is fb_response
mock_fb.assert_called_once_with("auto", "compression", reason="connection error")
class TestStaleBaseUrlWarning:
"""_resolve_auto() warns when OPENAI_BASE_URL conflicts with config provider (#5161)."""
def test_warns_when_openai_base_url_set_with_named_provider(self, monkeypatch, caplog):
"""Warning fires when OPENAI_BASE_URL is set but provider is a named provider."""
import agent.auxiliary_client as mod
# Reset the module-level flag so the warning fires
monkeypatch.setattr(mod, "_stale_base_url_warned", False)
monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:11434/v1")
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
patch("agent.auxiliary_client._read_main_model", return_value="google/gemini-flash"), \
caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"):
_resolve_auto()
assert any("OPENAI_BASE_URL is set" in rec.message for rec in caplog.records), \
"Expected a warning about stale OPENAI_BASE_URL"
assert mod._stale_base_url_warned is True
def test_no_warning_when_provider_is_custom(self, monkeypatch, caplog):
"""No warning when the provider is 'custom' — OPENAI_BASE_URL is expected."""
import agent.auxiliary_client as mod
monkeypatch.setattr(mod, "_stale_base_url_warned", False)
monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:11434/v1")
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
with patch("agent.auxiliary_client._read_main_provider", return_value="custom"), \
patch("agent.auxiliary_client._read_main_model", return_value="llama3"), \
patch("agent.auxiliary_client._resolve_custom_runtime",
return_value=("http://localhost:11434/v1", "test-key", None)), \
patch("agent.auxiliary_client.OpenAI") as mock_openai, \
caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"):
mock_openai.return_value = MagicMock()
_resolve_auto()
assert not any("OPENAI_BASE_URL is set" in rec.message for rec in caplog.records), \
"Should NOT warn when provider is 'custom'"
def test_no_warning_when_provider_is_named_custom(self, monkeypatch, caplog):
"""No warning when the provider is 'custom:myname' — base_url comes from config."""
import agent.auxiliary_client as mod
monkeypatch.setattr(mod, "_stale_base_url_warned", False)
monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:11434/v1")
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
with patch("agent.auxiliary_client._read_main_provider", return_value="custom:ollama-local"), \
patch("agent.auxiliary_client._read_main_model", return_value="llama3"), \
patch("agent.auxiliary_client.resolve_provider_client",
return_value=(MagicMock(), "llama3")), \
caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"):
_resolve_auto()
assert not any("OPENAI_BASE_URL is set" in rec.message for rec in caplog.records), \
"Should NOT warn when provider is 'custom:*'"
def test_no_warning_when_openai_base_url_not_set(self, monkeypatch, caplog):
"""No warning when OPENAI_BASE_URL is absent."""
import agent.auxiliary_client as mod
monkeypatch.setattr(mod, "_stale_base_url_warned", False)
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
patch("agent.auxiliary_client._read_main_model", return_value="google/gemini-flash"), \
caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"):
_resolve_auto()
assert not any("OPENAI_BASE_URL is set" in rec.message for rec in caplog.records), \
"Should NOT warn when OPENAI_BASE_URL is not set"
def test_warning_only_fires_once(self, monkeypatch, caplog):
"""Warning is suppressed after the first invocation."""
import agent.auxiliary_client as mod
monkeypatch.setattr(mod, "_stale_base_url_warned", False)
monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:11434/v1")
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
patch("agent.auxiliary_client._read_main_model", return_value="google/gemini-flash"), \
caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"):
_resolve_auto()
caplog.clear()
_resolve_auto()
assert not any("OPENAI_BASE_URL is set" in rec.message for rec in caplog.records), \
"Warning should not fire a second time"
-250
View File
@@ -1,250 +0,0 @@
"""Tests for the ContextEngine ABC and plugin slot."""
import json
import pytest
from typing import Any, Dict, List
from agent.context_engine import ContextEngine
from agent.context_compressor import ContextCompressor
# ---------------------------------------------------------------------------
# A minimal concrete engine for testing the ABC
# ---------------------------------------------------------------------------
class StubEngine(ContextEngine):
"""Minimal engine that satisfies the ABC without doing real work."""
def __init__(self, context_length=200000, threshold_pct=0.50):
self.context_length = context_length
self.threshold_tokens = int(context_length * threshold_pct)
self._compress_called = False
self._tools_called = []
@property
def name(self) -> str:
return "stub"
def update_from_response(self, usage: Dict[str, Any]) -> None:
self.last_prompt_tokens = usage.get("prompt_tokens", 0)
self.last_completion_tokens = usage.get("completion_tokens", 0)
self.last_total_tokens = usage.get("total_tokens", 0)
def should_compress(self, prompt_tokens: int = None) -> bool:
tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens
return tokens >= self.threshold_tokens
def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None) -> List[Dict[str, Any]]:
self._compress_called = True
self.compression_count += 1
# Trivial: just return as-is
return messages
def get_tool_schemas(self) -> List[Dict[str, Any]]:
return [
{
"name": "stub_search",
"description": "Search the stub engine",
"parameters": {"type": "object", "properties": {}},
}
]
def handle_tool_call(self, name: str, args: Dict[str, Any]) -> str:
self._tools_called.append(name)
return json.dumps({"ok": True, "tool": name})
# ---------------------------------------------------------------------------
# ABC contract tests
# ---------------------------------------------------------------------------
class TestContextEngineABC:
"""Verify the ABC enforces the required interface."""
def test_cannot_instantiate_abc_directly(self):
with pytest.raises(TypeError):
ContextEngine()
def test_missing_methods_raises(self):
"""A subclass missing required methods cannot be instantiated."""
class Incomplete(ContextEngine):
@property
def name(self):
return "incomplete"
with pytest.raises(TypeError):
Incomplete()
def test_stub_engine_satisfies_abc(self):
engine = StubEngine()
assert isinstance(engine, ContextEngine)
assert engine.name == "stub"
def test_compressor_is_context_engine(self):
c = ContextCompressor(model="test", quiet_mode=True, config_context_length=200000)
assert isinstance(c, ContextEngine)
assert c.name == "compressor"
# ---------------------------------------------------------------------------
# Default method behavior
# ---------------------------------------------------------------------------
class TestDefaults:
"""Verify ABC default implementations work correctly."""
def test_default_tool_schemas_empty(self):
engine = StubEngine()
# StubEngine overrides this, so test the base via super
assert ContextEngine.get_tool_schemas(engine) == []
def test_default_handle_tool_call_returns_error(self):
engine = StubEngine()
result = ContextEngine.handle_tool_call(engine, "unknown", {})
data = json.loads(result)
assert "error" in data
def test_default_get_status(self):
engine = StubEngine()
engine.last_prompt_tokens = 50000
status = engine.get_status()
assert status["last_prompt_tokens"] == 50000
assert status["context_length"] == 200000
assert status["threshold_tokens"] == 100000
assert 0 < status["usage_percent"] <= 100
def test_on_session_reset(self):
engine = StubEngine()
engine.last_prompt_tokens = 999
engine.compression_count = 3
engine.on_session_reset()
assert engine.last_prompt_tokens == 0
assert engine.compression_count == 0
def test_should_compress_preflight_default_false(self):
engine = StubEngine()
assert engine.should_compress_preflight([]) is False
# ---------------------------------------------------------------------------
# StubEngine behavior
# ---------------------------------------------------------------------------
class TestStubEngine:
def test_should_compress(self):
engine = StubEngine(context_length=100000, threshold_pct=0.50)
assert not engine.should_compress(40000)
assert engine.should_compress(50000)
assert engine.should_compress(60000)
def test_compress_tracks_count(self):
engine = StubEngine()
msgs = [{"role": "user", "content": "hello"}]
result = engine.compress(msgs)
assert result == msgs
assert engine._compress_called
assert engine.compression_count == 1
def test_tool_schemas(self):
engine = StubEngine()
schemas = engine.get_tool_schemas()
assert len(schemas) == 1
assert schemas[0]["name"] == "stub_search"
def test_handle_tool_call(self):
engine = StubEngine()
result = engine.handle_tool_call("stub_search", {})
assert json.loads(result)["ok"] is True
assert "stub_search" in engine._tools_called
def test_update_from_response(self):
engine = StubEngine()
engine.update_from_response({"prompt_tokens": 1000, "completion_tokens": 200, "total_tokens": 1200})
assert engine.last_prompt_tokens == 1000
assert engine.last_completion_tokens == 200
# ---------------------------------------------------------------------------
# ContextCompressor session reset via ABC
# ---------------------------------------------------------------------------
class TestCompressorSessionReset:
"""Verify ContextCompressor.on_session_reset() clears all state."""
def test_reset_clears_state(self):
c = ContextCompressor(model="test", quiet_mode=True, config_context_length=200000)
c.last_prompt_tokens = 50000
c.compression_count = 3
c._previous_summary = "some old summary"
c._context_probed = True
c._context_probe_persistable = True
c.on_session_reset()
assert c.last_prompt_tokens == 0
assert c.last_completion_tokens == 0
assert c.last_total_tokens == 0
assert c.compression_count == 0
assert c._context_probed is False
assert c._context_probe_persistable is False
assert c._previous_summary is None
# ---------------------------------------------------------------------------
# Plugin slot (PluginManager integration)
# ---------------------------------------------------------------------------
class TestPluginContextEngineSlot:
"""Test register_context_engine on PluginContext."""
def test_register_engine(self):
from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest
mgr = PluginManager()
manifest = PluginManifest(name="test-lcm")
ctx = PluginContext(manifest, mgr)
engine = StubEngine()
ctx.register_context_engine(engine)
assert mgr._context_engine is engine
assert mgr._context_engine.name == "stub"
def test_reject_second_engine(self):
from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest
mgr = PluginManager()
manifest = PluginManifest(name="test-lcm")
ctx = PluginContext(manifest, mgr)
engine1 = StubEngine()
engine2 = StubEngine()
ctx.register_context_engine(engine1)
ctx.register_context_engine(engine2) # should be rejected
assert mgr._context_engine is engine1
def test_reject_non_engine(self):
from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest
mgr = PluginManager()
manifest = PluginManifest(name="test-bad")
ctx = PluginContext(manifest, mgr)
ctx.register_context_engine("not an engine")
assert mgr._context_engine is None
def test_get_plugin_context_engine(self):
from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest, get_plugin_context_engine, _plugin_manager
import hermes_cli.plugins as plugins_mod
# Inject a test manager
old_mgr = plugins_mod._plugin_manager
try:
mgr = PluginManager()
plugins_mod._plugin_manager = mgr
assert get_plugin_context_engine() is None
engine = StubEngine()
mgr._context_engine = engine
assert get_plugin_context_engine() is engine
finally:
plugins_mod._plugin_manager = old_mgr
+35 -195
View File
@@ -1,37 +1,37 @@
"""Tests for MiniMax provider hardening — context lengths, thinking, catalog, beta headers, transport."""
"""Tests for MiniMax provider hardening — context lengths, thinking guard, catalog, beta headers."""
from unittest.mock import patch
class TestMinimaxContextLengths:
"""Verify context length entries match official docs (204,800 for all models).
"""Verify per-model context length entries for MiniMax models."""
Source: https://platform.minimax.io/docs/api-reference/text-anthropic-api
"""
def test_minimax_prefix_has_correct_context(self):
def test_m1_variants_have_1m_context(self):
from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS
assert DEFAULT_CONTEXT_LENGTHS["minimax"] == 204_800
# Keys are lowercase because the lookup lowercases model names
for model in ("minimax-m1", "minimax-m1-40k", "minimax-m1-80k",
"minimax-m1-128k", "minimax-m1-256k"):
assert model in DEFAULT_CONTEXT_LENGTHS, f"{model} missing from context lengths"
assert DEFAULT_CONTEXT_LENGTHS[model] == 1_000_000, f"{model} expected 1M"
def test_minimax_models_resolve_via_prefix(self):
from agent.model_metadata import get_model_context_length
# All MiniMax models should resolve to 204,800 via the "minimax" prefix
for model in ("MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"):
ctx = get_model_context_length(model, "")
assert ctx == 204_800, f"{model} expected 204800, got {ctx}"
def test_m2_variants_have_1m_context(self):
from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS
# Keys are lowercase because the lookup lowercases model names
for model in ("minimax-m2.5", "minimax-m2.7"):
assert model in DEFAULT_CONTEXT_LENGTHS, f"{model} missing from context lengths"
assert DEFAULT_CONTEXT_LENGTHS[model] == 1_048_576, f"{model} expected 1048576"
def test_minimax_prefix_fallback(self):
from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS
# The generic "minimax" prefix entry should be 1M for unknown models
assert DEFAULT_CONTEXT_LENGTHS["minimax"] == 1_048_576
class TestMinimaxThinkingSupport:
"""Verify that MiniMax gets manual thinking (not adaptive).
class TestMinimaxThinkingGuard:
"""Verify that build_anthropic_kwargs does NOT add thinking params for MiniMax models."""
MiniMax's Anthropic-compat endpoint officially supports the thinking
parameter (https://platform.minimax.io/docs/api-reference/text-anthropic-api).
It should get manual thinking (type=enabled + budget_tokens), NOT adaptive
thinking (which is Claude 4.6-only).
"""
def test_minimax_m27_gets_manual_thinking(self):
def test_no_thinking_for_minimax_m27(self):
from agent.anthropic_adapter import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="MiniMax-M2.7",
@@ -40,23 +40,19 @@ class TestMinimaxThinkingSupport:
max_tokens=4096,
reasoning_config={"enabled": True, "effort": "medium"},
)
assert "thinking" in kwargs
assert kwargs["thinking"]["type"] == "enabled"
assert "budget_tokens" in kwargs["thinking"]
# MiniMax should NOT get adaptive thinking or output_config
assert "thinking" not in kwargs
assert "output_config" not in kwargs
def test_minimax_m25_gets_manual_thinking(self):
def test_no_thinking_for_minimax_m1(self):
from agent.anthropic_adapter import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="MiniMax-M2.5",
model="MiniMax-M1-128k",
messages=[{"role": "user", "content": "hello"}],
tools=None,
max_tokens=4096,
reasoning_config={"enabled": True, "effort": "high"},
)
assert "thinking" in kwargs
assert kwargs["thinking"]["type"] == "enabled"
assert "thinking" not in kwargs
def test_thinking_still_works_for_claude(self):
from agent.anthropic_adapter import build_anthropic_kwargs
@@ -85,30 +81,25 @@ class TestMinimaxAuxModel:
class TestMinimaxModelCatalog:
"""Verify the model catalog matches official Anthropic-compat endpoint models.
"""Verify the model catalog includes M1 family and excludes deprecated models."""
Source: https://platform.minimax.io/docs/api-reference/text-anthropic-api
"""
def test_catalog_includes_current_models(self):
def test_catalog_includes_m1_family(self):
from hermes_cli.models import _PROVIDER_MODELS
for provider in ("minimax", "minimax-cn"):
models = _PROVIDER_MODELS[provider]
assert "MiniMax-M2.7" in models
assert "MiniMax-M2.5" in models
assert "MiniMax-M2.1" in models
assert "MiniMax-M2" in models
assert "MiniMax-M1" in models
assert "MiniMax-M1-40k" in models
assert "MiniMax-M1-80k" in models
assert "MiniMax-M1-128k" in models
assert "MiniMax-M1-256k" in models
def test_catalog_excludes_m1_family(self):
"""M1 models are not available on the /anthropic endpoint."""
def test_catalog_excludes_deprecated(self):
from hermes_cli.models import _PROVIDER_MODELS
for provider in ("minimax", "minimax-cn"):
models = _PROVIDER_MODELS[provider]
assert "MiniMax-M1" not in models
assert "MiniMax-M2.1" not in models
def test_catalog_excludes_highspeed(self):
"""Highspeed variants are available but not shown in default catalog
(users can still specify them manually)."""
from hermes_cli.models import _PROVIDER_MODELS
for provider in ("minimax", "minimax-cn"):
models = _PROVIDER_MODELS[provider]
@@ -211,154 +202,3 @@ class TestMinimaxBetaHeaders:
def test_common_betas_regular_url(self):
from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS
assert _common_betas_for_base_url("https://api.anthropic.com") == _COMMON_BETAS
class TestMinimaxApiMode:
"""Verify determine_api_mode returns anthropic_messages for MiniMax providers.
The MiniMax /anthropic endpoint speaks Anthropic Messages wire format,
not OpenAI chat completions. The overlay transport must reflect this
so that code paths calling determine_api_mode() without a base_url
(e.g. /model switch) get the correct api_mode.
"""
def test_minimax_returns_anthropic_messages(self):
from hermes_cli.providers import determine_api_mode
assert determine_api_mode("minimax") == "anthropic_messages"
def test_minimax_cn_returns_anthropic_messages(self):
from hermes_cli.providers import determine_api_mode
assert determine_api_mode("minimax-cn") == "anthropic_messages"
def test_minimax_with_url_also_works(self):
from hermes_cli.providers import determine_api_mode
# Even with explicit base_url, provider lookup takes priority
assert determine_api_mode("minimax", "https://api.minimax.io/anthropic") == "anthropic_messages"
def test_anthropic_still_returns_anthropic_messages(self):
from hermes_cli.providers import determine_api_mode
assert determine_api_mode("anthropic") == "anthropic_messages"
def test_openai_returns_chat_completions(self):
from hermes_cli.providers import determine_api_mode
# Sanity check: standard providers are unaffected
result = determine_api_mode("deepseek")
assert result == "chat_completions"
class TestMinimaxMaxOutput:
"""Verify _get_anthropic_max_output returns correct limits for MiniMax models.
MiniMax max output is 131,072 tokens (source: OpenClaw model definitions,
cross-referenced with MiniMax API behavior).
"""
def test_minimax_m27_output_limit(self):
from agent.anthropic_adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("MiniMax-M2.7") == 131_072
def test_minimax_m25_output_limit(self):
from agent.anthropic_adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("MiniMax-M2.5") == 131_072
def test_minimax_m2_output_limit(self):
from agent.anthropic_adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("MiniMax-M2") == 131_072
def test_claude_output_unaffected(self):
from agent.anthropic_adapter import _get_anthropic_max_output
# Sanity: Claude limits are not broken by the MiniMax entry
assert _get_anthropic_max_output("claude-sonnet-4-6") == 64_000
class TestMinimaxPreserveDots:
"""Verify that MiniMax model names preserve dots through the Anthropic adapter.
MiniMax model IDs like 'MiniMax-M2.7' must NOT have dots converted to
hyphens the endpoint expects the exact name with dots.
"""
def test_minimax_provider_preserves_dots(self):
from types import SimpleNamespace
agent = SimpleNamespace(provider="minimax", base_url="")
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is True
def test_minimax_cn_provider_preserves_dots(self):
from types import SimpleNamespace
agent = SimpleNamespace(provider="minimax-cn", base_url="")
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is True
def test_minimax_url_preserves_dots(self):
from types import SimpleNamespace
agent = SimpleNamespace(provider="custom", base_url="https://api.minimax.io/anthropic")
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is True
def test_minimax_cn_url_preserves_dots(self):
from types import SimpleNamespace
agent = SimpleNamespace(provider="custom", base_url="https://api.minimaxi.com/anthropic")
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is True
def test_anthropic_does_not_preserve_dots(self):
from types import SimpleNamespace
agent = SimpleNamespace(provider="anthropic", base_url="https://api.anthropic.com")
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is False
def test_normalize_preserves_m27_dot(self):
from agent.anthropic_adapter import normalize_model_name
assert normalize_model_name("MiniMax-M2.7", preserve_dots=True) == "MiniMax-M2.7"
def test_normalize_converts_without_preserve(self):
from agent.anthropic_adapter import normalize_model_name
# Without preserve_dots, dots become hyphens (broken for MiniMax)
assert normalize_model_name("MiniMax-M2.7", preserve_dots=False) == "MiniMax-M2-7"
class TestMinimaxSwitchModelCredentialGuard:
"""Verify switch_model() does not leak Anthropic credentials to MiniMax.
The __init__ path correctly guards against this (line 761), but switch_model()
must mirror that guard. Without it, /model switch to minimax with no explicit
api_key would fall back to resolve_anthropic_token() and send Anthropic creds
to the MiniMax endpoint.
"""
def test_switch_to_minimax_does_not_resolve_anthropic_token(self):
"""switch_model() should NOT call resolve_anthropic_token() for MiniMax."""
from unittest.mock import patch, MagicMock
with patch("run_agent.AIAgent.__init__", return_value=None):
from run_agent import AIAgent
agent = AIAgent.__new__(AIAgent)
agent.provider = "anthropic"
agent.model = "claude-sonnet-4"
agent.api_key = "sk-ant-fake"
agent.base_url = "https://api.anthropic.com"
agent.api_mode = "anthropic_messages"
agent._anthropic_base_url = "https://api.anthropic.com"
agent._anthropic_api_key = "sk-ant-fake"
agent._is_anthropic_oauth = False
agent._client_kwargs = {}
agent.client = None
agent._anthropic_client = MagicMock()
with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-leaked") as mock_resolve, \
patch("agent.anthropic_adapter._is_oauth_token", return_value=False):
agent.switch_model(
new_model="MiniMax-M2.7",
new_provider="minimax",
api_mode="anthropic_messages",
api_key="mm-key-123",
base_url="https://api.minimax.io/anthropic",
)
# resolve_anthropic_token should NOT be called for non-Anthropic providers
mock_resolve.assert_not_called()
# The key passed to build_anthropic_client should be the MiniMax key
build_args = mock_build.call_args
assert build_args[0][0] == "mm-key-123"
-18
View File
@@ -222,24 +222,6 @@ class TestGetModelContextLength:
mock_fetch.return_value = {}
assert get_model_context_length("openai/gpt-4o") == 128000
@patch("agent.model_metadata.fetch_model_metadata")
def test_qwen3_coder_plus_context_length(self, mock_fetch):
"""qwen3-coder-plus has a 1M context window, not the generic 128K Qwen default."""
mock_fetch.return_value = {}
assert get_model_context_length("qwen3-coder-plus") == 1000000
@patch("agent.model_metadata.fetch_model_metadata")
def test_qwen3_coder_context_length(self, mock_fetch):
"""qwen3-coder has a 256K context window, not the generic 128K Qwen default."""
mock_fetch.return_value = {}
assert get_model_context_length("qwen3-coder") == 262144
@patch("agent.model_metadata.fetch_model_metadata")
def test_qwen_generic_context_length(self, mock_fetch):
"""Generic qwen models still get the 128K default."""
mock_fetch.return_value = {}
assert get_model_context_length("qwen3-plus") == 131072
@patch("agent.model_metadata.fetch_model_metadata")
def test_api_missing_context_length_key(self, mock_fetch):
"""Model in API but without context_length → defaults to 128000."""
-66
View File
@@ -1,66 +0,0 @@
"""Tests for CLI manual compression messaging."""
from unittest.mock import MagicMock, patch
from tests.cli.test_cli_init import _make_cli
def _make_history() -> list[dict[str, str]]:
return [
{"role": "user", "content": "one"},
{"role": "assistant", "content": "two"},
{"role": "user", "content": "three"},
{"role": "assistant", "content": "four"},
]
def test_manual_compress_reports_noop_without_success_banner(capsys):
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent._compress_context.return_value = (list(history), "")
def _estimate(messages):
assert messages == history
return 100
with patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate):
shell._manual_compress()
output = capsys.readouterr().out
assert "No changes from compression" in output
assert "✅ Compressed" not in output
assert "Rough transcript estimate: ~100 tokens (unchanged)" in output
def test_manual_compress_explains_when_token_estimate_rises(capsys):
shell = _make_cli()
history = _make_history()
compressed = [
history[0],
{"role": "assistant", "content": "Dense summary that still counts as more tokens."},
history[-1],
]
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent._compress_context.return_value = (compressed, "")
def _estimate(messages):
if messages == history:
return 100
if messages == compressed:
return 120
raise AssertionError(f"unexpected transcript: {messages!r}")
with patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate):
shell._manual_compress()
output = capsys.readouterr().out
assert "✅ Compressed: 4 → 3 messages" in output
assert "Rough transcript estimate: ~100 → ~120 tokens" in output
assert "denser summaries" in output
+58 -150
View File
@@ -1,4 +1,4 @@
"""Shared fixtures for gateway e2e tests (Telegram, Discord).
"""Shared fixtures for Telegram gateway e2e tests.
These tests exercise the full async message flow:
adapter.handle_message(event)
@@ -14,22 +14,19 @@ import sys
import uuid
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from unittest.mock import AsyncMock, MagicMock
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, SendResult
from gateway.session import SessionEntry, SessionSource, build_session_key
# Platform library mocks
#Ensure telegram module is available (mock it if not installed)
# Ensure telegram module is available (mock it if not installed)
def _ensure_telegram_mock():
"""Install mock telegram modules so TelegramAdapter can be imported."""
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
return # Real library installed
return # Real library installed
telegram_mod = MagicMock()
telegram_mod.Update = MagicMock()
@@ -54,118 +51,24 @@ def _ensure_telegram_mock():
sys.modules.setdefault(name, telegram_mod)
# Ensure discord module is available (mock it if not installed)
def _ensure_discord_mock():
"""Install mock discord modules so DiscordAdapter can be imported."""
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
return # Real library installed
discord_mod = MagicMock()
discord_mod.Intents.default.return_value = MagicMock()
discord_mod.DMChannel = type("DMChannel", (), {})
discord_mod.Thread = type("Thread", (), {})
discord_mod.ForumChannel = type("ForumChannel", (), {})
discord_mod.Interaction = object
discord_mod.app_commands = SimpleNamespace(
describe=lambda **kwargs: (lambda fn: fn),
choices=lambda **kwargs: (lambda fn: fn),
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
)
discord_mod.opus.is_loaded.return_value = True
ext_mod = MagicMock()
commands_mod = MagicMock()
commands_mod.Bot = MagicMock
ext_mod.commands = commands_mod
sys.modules.setdefault("discord", discord_mod)
sys.modules.setdefault("discord.ext", ext_mod)
sys.modules.setdefault("discord.ext.commands", commands_mod)
sys.modules.setdefault("discord.opus", discord_mod.opus)
def _ensure_slack_mock():
"""Install mock slack modules so SlackAdapter can be imported."""
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
return # Real library installed
slack_bolt = MagicMock()
slack_bolt.async_app.AsyncApp = MagicMock
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
slack_sdk = MagicMock()
slack_sdk.web.async_client.AsyncWebClient = MagicMock
for name, mod in [
("slack_bolt", slack_bolt),
("slack_bolt.async_app", slack_bolt.async_app),
("slack_bolt.adapter", slack_bolt.adapter),
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler),
("slack_sdk", slack_sdk),
("slack_sdk.web", slack_sdk.web),
("slack_sdk.web.async_client", slack_sdk.web.async_client),
]:
sys.modules.setdefault(name, mod)
_ensure_telegram_mock()
_ensure_discord_mock()
_ensure_slack_mock()
from gateway.platforms.discord import DiscordAdapter # noqa: E402
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
import gateway.platforms.slack as _slack_mod # noqa: E402
_slack_mod.SLACK_AVAILABLE = True
from gateway.platforms.slack import SlackAdapter # noqa: E402
#GatewayRunner factory (based on tests/gateway/test_status_command.py)
# Platform-generic factories
def make_source(platform: Platform, chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1") -> SessionSource:
return SessionSource(
platform=platform,
chat_id=chat_id,
user_id=user_id,
user_name="e2e_tester",
chat_type="dm",
)
def make_session_entry(platform: Platform, source: SessionSource = None) -> SessionEntry:
source = source or make_source(platform)
return SessionEntry(
session_key=build_session_key(source),
session_id=f"sess-{uuid.uuid4().hex[:8]}",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=platform,
chat_type="dm",
)
def make_event(platform: Platform, text: str = "/help", chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1") -> MessageEvent:
return MessageEvent(
text=text,
source=make_source(platform, chat_id, user_id),
message_id=f"msg-{uuid.uuid4().hex[:8]}",
)
def make_runner(platform: Platform, session_entry: SessionEntry = None) -> "GatewayRunner":
def make_runner(session_entry: SessionEntry) -> "GatewayRunner":
"""Create a GatewayRunner with mocked internals for e2e testing.
Skips __init__ to avoid filesystem/network side effects.
All command-dispatch dependencies are wired manually.
"""
from gateway.run import GatewayRunner
if session_entry is None:
session_entry = make_session_entry(platform)
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={platform: PlatformConfig(enabled=True, token="e2e-test-token")}
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="e2e-test-token")}
)
runner.adapters = {}
runner._voice_mode = {}
@@ -196,6 +99,7 @@ def make_runner(platform: Platform, session_entry: SessionEntry = None) -> "Gate
runner._capture_gateway_honcho_if_configured = lambda *a, **kw: None
runner._emit_gateway_run_progress = AsyncMock()
# Pairing store (used by authorization rejection path)
runner.pairing_store = MagicMock()
runner.pairing_store._is_rate_limited = MagicMock(return_value=False)
runner.pairing_store.generate_code = MagicMock(return_value="ABC123")
@@ -203,63 +107,67 @@ def make_runner(platform: Platform, session_entry: SessionEntry = None) -> "Gate
return runner
def make_adapter(platform: Platform, runner=None):
"""Create a platform adapter wired to *runner*, with send methods mocked."""
if runner is None:
runner = make_runner(platform)
#TelegramAdapter factory
def make_adapter(runner) -> TelegramAdapter:
"""Create a TelegramAdapter wired to *runner*, with send methods mocked.
connect() is NOT called no polling, no token lock, no real HTTP.
"""
config = PlatformConfig(enabled=True, token="e2e-test-token")
adapter = TelegramAdapter(config)
if platform == Platform.DISCORD:
with patch.object(DiscordAdapter, "_load_participated_threads", return_value=set()):
adapter = DiscordAdapter(config)
platform_key = Platform.DISCORD
elif platform == Platform.SLACK:
adapter = SlackAdapter(config)
platform_key = Platform.SLACK
else:
adapter = TelegramAdapter(config)
platform_key = Platform.TELEGRAM
# Mock outbound methods so tests can capture what was sent
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="e2e-resp-1"))
adapter.send_typing = AsyncMock()
# Wire adapter ↔ runner
adapter.set_message_handler(runner._handle_message)
runner.adapters[platform_key] = adapter
runner.adapters[Platform.TELEGRAM] = adapter
return adapter
async def send_and_capture(adapter, text: str, platform: Platform, **event_kwargs) -> AsyncMock:
"""Send a message through the full e2e flow and return the send mock."""
event = make_event(platform, text, **event_kwargs)
#Helpers
def make_source(chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1") -> SessionSource:
return SessionSource(
platform=Platform.TELEGRAM,
chat_id=chat_id,
user_id=user_id,
user_name="e2e_tester",
chat_type="dm",
)
def make_event(text: str, chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1") -> MessageEvent:
return MessageEvent(
text=text,
source=make_source(chat_id, user_id),
message_id=f"msg-{uuid.uuid4().hex[:8]}",
)
def make_session_entry(source: SessionSource = None) -> SessionEntry:
source = source or make_source()
return SessionEntry(
session_key=build_session_key(source),
session_id=f"sess-{uuid.uuid4().hex[:8]}",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.TELEGRAM,
chat_type="dm",
)
async def send_and_capture(adapter: TelegramAdapter, text: str, **event_kwargs) -> AsyncMock:
"""Send a message through the full e2e flow and return the send mock.
Drives: adapter.handle_message background task runner dispatch adapter.send.
"""
event = make_event(text, **event_kwargs)
adapter.send.reset_mock()
await adapter.handle_message(event)
# Let the background task complete
await asyncio.sleep(0.3)
return adapter.send
# Parametrized fixtures for platform-generic tests
@pytest.fixture(params=[Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK], ids=["telegram", "discord", "slack"])
def platform(request):
return request.param
@pytest.fixture()
def source(platform):
return make_source(platform)
@pytest.fixture()
def session_entry(platform, source):
return make_session_entry(platform, source)
@pytest.fixture()
def runner(platform, session_entry):
return make_runner(platform, session_entry)
@pytest.fixture()
def adapter(platform, runner):
return make_adapter(platform, runner)
@@ -1,4 +1,4 @@
"""E2E tests for gateway slash commands (Telegram, Discord).
"""E2E tests for Telegram gateway slash commands.
Each test drives a message through the full async pipeline:
adapter.handle_message(event)
@@ -7,7 +7,6 @@ Each test drives a message through the full async pipeline:
adapter.send() (captured for assertions)
No LLM involved only gateway-level commands are tested.
Tests are parametrized over platforms via the ``platform`` fixture in conftest.
"""
import asyncio
@@ -16,15 +15,46 @@ from unittest.mock import AsyncMock
import pytest
from gateway.platforms.base import SendResult
from tests.e2e.conftest import make_event, send_and_capture
from tests.e2e.conftest import (
make_adapter,
make_event,
make_runner,
make_session_entry,
make_source,
send_and_capture,
)
class TestSlashCommands:
#Fixtures
@pytest.fixture()
def source():
return make_source()
@pytest.fixture()
def session_entry(source):
return make_session_entry(source)
@pytest.fixture()
def runner(session_entry):
return make_runner(session_entry)
@pytest.fixture()
def adapter(runner):
return make_adapter(runner)
#Tests
class TestTelegramSlashCommands:
"""Gateway slash commands dispatched through the full adapter pipeline."""
@pytest.mark.asyncio
async def test_help_returns_command_list(self, adapter, platform):
send = await send_and_capture(adapter, "/help", platform)
async def test_help_returns_command_list(self, adapter):
send = await send_and_capture(adapter, "/help")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
@@ -32,23 +62,24 @@ class TestSlashCommands:
assert "/status" in response_text
@pytest.mark.asyncio
async def test_status_shows_session_info(self, adapter, platform):
send = await send_and_capture(adapter, "/status", platform)
async def test_status_shows_session_info(self, adapter):
send = await send_and_capture(adapter, "/status")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
# Status output includes session metadata
assert "session" in response_text.lower() or "Session" in response_text
@pytest.mark.asyncio
async def test_new_resets_session(self, adapter, runner, platform):
send = await send_and_capture(adapter, "/new", platform)
async def test_new_resets_session(self, adapter, runner):
send = await send_and_capture(adapter, "/new")
send.assert_called_once()
runner.session_store.reset_session.assert_called_once()
@pytest.mark.asyncio
async def test_stop_when_no_agent_running(self, adapter, platform):
send = await send_and_capture(adapter, "/stop", platform)
async def test_stop_when_no_agent_running(self, adapter):
send = await send_and_capture(adapter, "/stop")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
@@ -56,8 +87,8 @@ class TestSlashCommands:
assert "no" in response_lower or "stop" in response_lower or "not running" in response_lower
@pytest.mark.asyncio
async def test_commands_shows_listing(self, adapter, platform):
send = await send_and_capture(adapter, "/commands", platform)
async def test_commands_shows_listing(self, adapter):
send = await send_and_capture(adapter, "/commands")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
@@ -65,25 +96,25 @@ class TestSlashCommands:
assert "/" in response_text
@pytest.mark.asyncio
async def test_sequential_commands_share_session(self, adapter, platform):
async def test_sequential_commands_share_session(self, adapter):
"""Two commands from the same chat_id should both succeed."""
send_help = await send_and_capture(adapter, "/help", platform)
send_help = await send_and_capture(adapter, "/help")
send_help.assert_called_once()
send_status = await send_and_capture(adapter, "/status", platform)
send_status = await send_and_capture(adapter, "/status")
send_status.assert_called_once()
@pytest.mark.asyncio
async def test_provider_shows_current_provider(self, adapter, platform):
send = await send_and_capture(adapter, "/provider", platform)
async def test_provider_shows_current_provider(self, adapter):
send = await send_and_capture(adapter, "/provider")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "provider" in response_text.lower()
@pytest.mark.asyncio
async def test_verbose_responds(self, adapter, platform):
send = await send_and_capture(adapter, "/verbose", platform)
async def test_verbose_responds(self, adapter):
send = await send_and_capture(adapter, "/verbose")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
@@ -91,50 +122,42 @@ class TestSlashCommands:
assert "verbose" in response_text.lower() or "tool_progress" in response_text
@pytest.mark.asyncio
async def test_personality_lists_options(self, adapter, platform):
send = await send_and_capture(adapter, "/personality", platform)
async def test_personality_lists_options(self, adapter):
send = await send_and_capture(adapter, "/personality")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "personalit" in response_text.lower() # matches "personality" or "personalities"
@pytest.mark.asyncio
async def test_yolo_toggles_mode(self, adapter, platform):
send = await send_and_capture(adapter, "/yolo", platform)
async def test_yolo_toggles_mode(self, adapter):
send = await send_and_capture(adapter, "/yolo")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "yolo" in response_text.lower()
@pytest.mark.asyncio
async def test_compress_command(self, adapter, platform):
send = await send_and_capture(adapter, "/compress", platform)
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "compress" in response_text.lower() or "context" in response_text.lower()
class TestSessionLifecycle:
"""Verify session state changes across command sequences."""
@pytest.mark.asyncio
async def test_new_then_status_reflects_reset(self, adapter, runner, session_entry, platform):
async def test_new_then_status_reflects_reset(self, adapter, runner, session_entry):
"""After /new, /status should report the fresh session."""
await send_and_capture(adapter, "/new", platform)
await send_and_capture(adapter, "/new")
runner.session_store.reset_session.assert_called_once()
send = await send_and_capture(adapter, "/status", platform)
send = await send_and_capture(adapter, "/status")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
# Session ID from the entry should appear in the status output
assert session_entry.session_id[:8] in response_text
@pytest.mark.asyncio
async def test_new_is_idempotent(self, adapter, runner, platform):
async def test_new_is_idempotent(self, adapter, runner):
"""/new called twice should not crash."""
await send_and_capture(adapter, "/new", platform)
await send_and_capture(adapter, "/new", platform)
await send_and_capture(adapter, "/new")
await send_and_capture(adapter, "/new")
assert runner.session_store.reset_session.call_count == 2
@@ -142,11 +165,11 @@ class TestAuthorization:
"""Verify the pipeline handles unauthorized users."""
@pytest.mark.asyncio
async def test_unauthorized_user_gets_pairing_response(self, adapter, runner, platform):
async def test_unauthorized_user_gets_pairing_response(self, adapter, runner):
"""Unauthorized DM should trigger pairing code, not a command response."""
runner._is_user_authorized = lambda _source: False
event = make_event(platform, "/help")
event = make_event("/help")
adapter.send.reset_mock()
await adapter.handle_message(event)
await asyncio.sleep(0.3)
@@ -158,11 +181,11 @@ class TestAuthorization:
assert "recognize" in response_text.lower() or "pair" in response_text.lower() or "ABC123" in response_text
@pytest.mark.asyncio
async def test_unauthorized_user_does_not_get_help(self, adapter, runner, platform):
async def test_unauthorized_user_does_not_get_help(self, adapter, runner):
"""Unauthorized user should NOT see the help command output."""
runner._is_user_authorized = lambda _source: False
event = make_event(platform, "/help")
event = make_event("/help")
adapter.send.reset_mock()
await adapter.handle_message(event)
await asyncio.sleep(0.3)
@@ -177,12 +200,12 @@ class TestSendFailureResilience:
"""Verify the pipeline handles send failures gracefully."""
@pytest.mark.asyncio
async def test_send_failure_does_not_crash_pipeline(self, adapter, platform):
async def test_send_failure_does_not_crash_pipeline(self, adapter):
"""If send() returns failure, the pipeline should not raise."""
adapter.send = AsyncMock(return_value=SendResult(success=False, error="network timeout"))
adapter.set_message_handler(adapter._message_handler) # re-wire with same handler
adapter.set_message_handler(adapter._message_handler) # re-wire with same handler
event = make_event(platform, "/help")
event = make_event("/help")
# Should not raise — pipeline handles send failures internally
await adapter.handle_message(event)
await asyncio.sleep(0.3)
-110
View File
@@ -1,110 +0,0 @@
import asyncio
from unittest.mock import AsyncMock, MagicMock
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult
from gateway.restart import DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
from gateway.run import GatewayRunner
from gateway.session import SessionSource
class RestartTestAdapter(BasePlatformAdapter):
def __init__(self):
super().__init__(PlatformConfig(enabled=True, token="***"), Platform.TELEGRAM)
self.sent: list[str] = []
async def connect(self):
return True
async def disconnect(self):
return None
async def send(self, chat_id, content, reply_to=None, metadata=None):
self.sent.append(content)
return SendResult(success=True, message_id="1")
async def send_typing(self, chat_id, metadata=None):
return None
async def get_chat_info(self, chat_id):
return {"id": chat_id}
def make_restart_source(chat_id: str = "123456", chat_type: str = "dm") -> SessionSource:
return SessionSource(
platform=Platform.TELEGRAM,
chat_id=chat_id,
chat_type=chat_type,
)
def make_restart_runner(
adapter: BasePlatformAdapter | None = None,
) -> tuple[GatewayRunner, BasePlatformAdapter]:
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
)
runner._running = True
runner._shutdown_event = asyncio.Event()
runner._exit_reason = None
runner._exit_code = None
runner._running_agents = {}
runner._running_agents_ts = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._pending_model_notes = {}
runner._background_tasks = set()
runner._draining = False
runner._restart_requested = False
runner._restart_task_started = False
runner._restart_detached = False
runner._restart_via_service = False
runner._restart_drain_timeout = DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
runner._stop_task = None
runner._busy_input_mode = "interrupt"
runner._update_prompt_pending = {}
runner._voice_mode = {}
runner._session_model_overrides = {}
runner._shutdown_all_gateway_honcho = lambda: None
runner._update_runtime_status = MagicMock()
runner._queue_or_replace_pending_event = GatewayRunner._queue_or_replace_pending_event.__get__(
runner, GatewayRunner
)
runner._session_key_for_source = GatewayRunner._session_key_for_source.__get__(
runner, GatewayRunner
)
runner._handle_active_session_busy_message = (
GatewayRunner._handle_active_session_busy_message.__get__(runner, GatewayRunner)
)
runner._handle_restart_command = GatewayRunner._handle_restart_command.__get__(
runner, GatewayRunner
)
runner._status_action_label = GatewayRunner._status_action_label.__get__(
runner, GatewayRunner
)
runner._status_action_gerund = GatewayRunner._status_action_gerund.__get__(
runner, GatewayRunner
)
runner._queue_during_drain_enabled = GatewayRunner._queue_during_drain_enabled.__get__(
runner, GatewayRunner
)
runner._running_agent_count = GatewayRunner._running_agent_count.__get__(
runner, GatewayRunner
)
runner._launch_detached_restart_command = GatewayRunner._launch_detached_restart_command.__get__(
runner, GatewayRunner
)
runner.request_restart = GatewayRunner.request_restart.__get__(runner, GatewayRunner)
runner._is_user_authorized = lambda _source: True
runner.hooks = MagicMock()
runner.hooks.emit = AsyncMock()
runner.pairing_store = MagicMock()
runner.session_store = MagicMock()
runner.delivery_router = MagicMock()
platform_adapter = adapter or RestartTestAdapter()
platform_adapter.set_message_handler(AsyncMock(return_value=None))
platform_adapter.set_busy_session_handler(runner._handle_active_session_busy_message)
runner.adapters = {Platform.TELEGRAM: platform_adapter}
return runner, platform_adapter
+6 -26
View File
@@ -464,7 +464,7 @@ class TestChatCompletionsEndpoint:
@pytest.mark.asyncio
async def test_stream_includes_tool_progress(self, adapter):
"""tool_progress_callback fires → progress appears as custom SSE event, not in delta.content."""
"""tool_progress_callback fires → progress appears in the SSE stream."""
import asyncio
app = _create_app(adapter)
@@ -495,26 +495,8 @@ class TestChatCompletionsEndpoint:
assert resp.status == 200
body = await resp.text()
assert "[DONE]" in body
# Tool progress must appear as a custom SSE event, not in
# delta.content — prevents model from learning to imitate
# markers instead of calling tools (#6972).
assert "event: hermes.tool.progress" in body
assert '"tool": "terminal"' in body
assert '"label": "ls -la"' in body
# The progress marker must NOT appear inside any
# chat.completion.chunk delta.content field.
import json as _json
for line in body.splitlines():
if line.startswith("data: ") and line.strip() != "data: [DONE]":
try:
chunk = _json.loads(line[len("data: "):])
except _json.JSONDecodeError:
continue
if chunk.get("object") == "chat.completion.chunk":
for choice in chunk.get("choices", []):
content = choice.get("delta", {}).get("content", "")
# Tool emoji markers must never leak into content
assert "ls -la" not in content or content == "Here are the files."
# Tool progress message must appear in the stream
assert "ls -la" in body
# Final content must also be present
assert "Here are the files." in body
@@ -550,12 +532,10 @@ class TestChatCompletionsEndpoint:
)
assert resp.status == 200
body = await resp.text()
# Internal _thinking event should NOT appear anywhere
# Internal _thinking event should NOT appear
assert "some internal state" not in body
# Real tool progress should appear as custom SSE event
assert "event: hermes.tool.progress" in body
assert '"tool": "web_search"' in body
assert '"label": "Python docs"' in body
# Real tool progress should appear
assert "Python docs" in body
@pytest.mark.asyncio
async def test_no_user_message_returns_400(self, adapter):
@@ -345,11 +345,6 @@ class TestBlockingApprovalE2E:
def setup_method(self):
_clear_approval_state()
os.environ.pop("HERMES_YOLO_MODE", None)
os.environ.pop("HERMES_INTERACTIVE", None)
os.environ.pop("HERMES_GATEWAY_SESSION", None)
os.environ.pop("HERMES_EXEC_ASK", None)
os.environ.pop("HERMES_SESSION_KEY", None)
def test_blocking_approval_approve_once(self):
"""check_all_command_guards blocks until resolve_gateway_approval is called."""
@@ -369,7 +364,6 @@ class TestBlockingApprovalE2E:
from tools.approval import reset_current_session_key, set_current_session_key
token = set_current_session_key(session_key)
os.environ["HERMES_GATEWAY_SESSION"] = "1"
os.environ["HERMES_EXEC_ASK"] = "1"
os.environ["HERMES_SESSION_KEY"] = session_key
try:
@@ -377,7 +371,6 @@ class TestBlockingApprovalE2E:
"rm -rf /important", "local"
)
finally:
os.environ.pop("HERMES_GATEWAY_SESSION", None)
os.environ.pop("HERMES_EXEC_ASK", None)
os.environ.pop("HERMES_SESSION_KEY", None)
reset_current_session_key(token)
@@ -417,7 +410,6 @@ class TestBlockingApprovalE2E:
from tools.approval import reset_current_session_key, set_current_session_key
token = set_current_session_key(session_key)
os.environ["HERMES_GATEWAY_SESSION"] = "1"
os.environ["HERMES_EXEC_ASK"] = "1"
os.environ["HERMES_SESSION_KEY"] = session_key
try:
@@ -425,7 +417,6 @@ class TestBlockingApprovalE2E:
"rm -rf /important", "local"
)
finally:
os.environ.pop("HERMES_GATEWAY_SESSION", None)
os.environ.pop("HERMES_EXEC_ASK", None)
os.environ.pop("HERMES_SESSION_KEY", None)
reset_current_session_key(token)
@@ -460,7 +451,6 @@ class TestBlockingApprovalE2E:
from tools.approval import reset_current_session_key, set_current_session_key
token = set_current_session_key(session_key)
os.environ["HERMES_GATEWAY_SESSION"] = "1"
os.environ["HERMES_EXEC_ASK"] = "1"
os.environ["HERMES_SESSION_KEY"] = session_key
try:
@@ -470,7 +460,6 @@ class TestBlockingApprovalE2E:
"rm -rf /important", "local"
)
finally:
os.environ.pop("HERMES_GATEWAY_SESSION", None)
os.environ.pop("HERMES_EXEC_ASK", None)
os.environ.pop("HERMES_SESSION_KEY", None)
reset_current_session_key(token)
@@ -502,13 +491,11 @@ class TestBlockingApprovalE2E:
from tools.approval import reset_current_session_key, set_current_session_key
token = set_current_session_key(session_key)
os.environ["HERMES_GATEWAY_SESSION"] = "1"
os.environ["HERMES_EXEC_ASK"] = "1"
os.environ["HERMES_SESSION_KEY"] = session_key
try:
results[idx] = check_all_command_guards(cmd, "local")
finally:
os.environ.pop("HERMES_GATEWAY_SESSION", None)
os.environ.pop("HERMES_EXEC_ASK", None)
os.environ.pop("HERMES_SESSION_KEY", None)
reset_current_session_key(token)
@@ -559,13 +546,11 @@ class TestBlockingApprovalE2E:
from tools.approval import reset_current_session_key, set_current_session_key
token = set_current_session_key(session_key)
os.environ["HERMES_GATEWAY_SESSION"] = "1"
os.environ["HERMES_EXEC_ASK"] = "1"
os.environ["HERMES_SESSION_KEY"] = session_key
try:
results[idx] = check_all_command_guards(cmd, "local")
finally:
os.environ.pop("HERMES_GATEWAY_SESSION", None)
os.environ.pop("HERMES_EXEC_ASK", None)
os.environ.pop("HERMES_SESSION_KEY", None)
reset_current_session_key(token)
-121
View File
@@ -1,121 +0,0 @@
"""Tests for gateway /compress user-facing messaging."""
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent
from gateway.session import SessionEntry, SessionSource, build_session_key
def _make_source() -> SessionSource:
return SessionSource(
platform=Platform.TELEGRAM,
user_id="u1",
chat_id="c1",
user_name="tester",
chat_type="dm",
)
def _make_event(text: str = "/compress") -> MessageEvent:
return MessageEvent(text=text, source=_make_source(), message_id="m1")
def _make_history() -> list[dict[str, str]]:
return [
{"role": "user", "content": "one"},
{"role": "assistant", "content": "two"},
{"role": "user", "content": "three"},
{"role": "assistant", "content": "four"},
]
def _make_runner(history: list[dict[str, str]]):
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
)
session_entry = SessionEntry(
session_key=build_session_key(_make_source()),
session_id="sess-1",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.TELEGRAM,
chat_type="dm",
)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = session_entry
runner.session_store.load_transcript.return_value = history
runner.session_store.rewrite_transcript = MagicMock()
runner.session_store.update_session = MagicMock()
runner.session_store._save = MagicMock()
return runner
@pytest.mark.asyncio
async def test_compress_command_reports_noop_without_success_banner():
history = _make_history()
runner = _make_runner(history)
agent_instance = MagicMock()
agent_instance.context_compressor.protect_first_n = 0
agent_instance.context_compressor._align_boundary_forward.return_value = 0
agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2
agent_instance.session_id = "sess-1"
agent_instance._compress_context.return_value = (list(history), "")
def _estimate(messages):
assert messages == history
return 100
with (
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}),
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
patch("run_agent.AIAgent", return_value=agent_instance),
patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate),
):
result = await runner._handle_compress_command(_make_event())
assert "No changes from compression" in result
assert "Compressed:" not in result
assert "Rough transcript estimate: ~100 tokens (unchanged)" in result
@pytest.mark.asyncio
async def test_compress_command_explains_when_token_estimate_rises():
history = _make_history()
compressed = [
history[0],
{"role": "assistant", "content": "Dense summary that still counts as more tokens."},
history[-1],
]
runner = _make_runner(history)
agent_instance = MagicMock()
agent_instance.context_compressor.protect_first_n = 0
agent_instance.context_compressor._align_boundary_forward.return_value = 0
agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2
agent_instance.session_id = "sess-1"
agent_instance._compress_context.return_value = (compressed, "")
def _estimate(messages):
if messages == history:
return 100
if messages == compressed:
return 120
raise AssertionError(f"unexpected transcript: {messages!r}")
with (
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}),
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
patch("run_agent.AIAgent", return_value=agent_instance),
patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate),
):
result = await runner._handle_compress_command(_make_event())
assert "Compressed: 4 → 3 messages" in result
assert "Rough transcript estimate: ~100 → ~120 tokens" in result
assert "denser summaries" in result
-44
View File
@@ -1,44 +0,0 @@
"""Tests for fallback-eviction gating on failed runs (#7130).
When a run fails, the gateway must NOT evict the cached agent doing so
forces MCP reinit on the next message, creating a CPU-burning restart loop.
Eviction should only happen on successful runs where fallback activated.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
class TestFallbackEvictionGating:
"""The fallback-eviction code path should skip eviction on failed runs."""
def test_failed_run_does_not_evict_cached_agent(self):
"""When result has failed=True, the cached agent should NOT be evicted."""
# The fix: `and not _run_failed` guard on the eviction check.
# Simulate the variables that the eviction block uses.
result = {"failed": True, "final_response": None, "error": "400 invalid model"}
_run_failed = result.get("failed") if result else False
assert _run_failed is True, "Failed run should be detected"
def test_successful_run_allows_eviction(self):
"""When result is successful, fallback eviction should proceed."""
result = {"completed": True, "final_response": "Hello!", "failed": False}
_run_failed = result.get("failed") if result else False
assert _run_failed is False, "Successful run should not be flagged"
def test_none_result_treated_as_not_failed(self):
"""When result is None (edge case), treat as not-failed."""
result = None
_run_failed = result.get("failed") if result else False
assert _run_failed is False
def test_missing_failed_key_treated_as_not_failed(self):
"""When result dict doesn't have 'failed' key, treat as not-failed."""
result = {"completed": True, "final_response": "Hello!"}
_run_failed = result.get("failed") if result else False
assert not _run_failed, "Missing 'failed' key should be falsy"
+44 -84
View File
@@ -3,15 +3,43 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.platforms.base import MessageEvent
from gateway.restart import GATEWAY_SERVICE_RESTART_EXIT_CODE
from gateway.session import build_session_key
from tests.gateway.restart_test_helpers import make_restart_runner, make_restart_source
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult
from gateway.run import GatewayRunner
from gateway.session import SessionSource, build_session_key
class StubAdapter(BasePlatformAdapter):
def __init__(self):
super().__init__(PlatformConfig(enabled=True, token="***"), Platform.TELEGRAM)
async def connect(self):
return True
async def disconnect(self):
return None
async def send(self, chat_id, content, reply_to=None, metadata=None):
return SendResult(success=True, message_id="1")
async def send_typing(self, chat_id, metadata=None):
return None
async def get_chat_info(self, chat_id):
return {"id": chat_id}
def _source(chat_id="123456", chat_type="dm"):
return SessionSource(
platform=Platform.TELEGRAM,
chat_id=chat_id,
chat_type=chat_type,
)
@pytest.mark.asyncio
async def test_cancel_background_tasks_cancels_inflight_message_processing():
_runner, adapter = make_restart_runner()
adapter = StubAdapter()
release = asyncio.Event()
async def block_forever(_event):
@@ -19,7 +47,7 @@ async def test_cancel_background_tasks_cancels_inflight_message_processing():
return None
adapter.set_message_handler(block_forever)
event = MessageEvent(text="work", source=make_restart_source(), message_id="1")
event = MessageEvent(text="work", source=_source(), message_id="1")
await adapter.handle_message(event)
await asyncio.sleep(0)
@@ -37,11 +65,17 @@ async def test_cancel_background_tasks_cancels_inflight_message_processing():
@pytest.mark.asyncio
async def test_gateway_stop_interrupts_running_agents_and_cancels_adapter_tasks():
runner, adapter = make_restart_runner()
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")})
runner._running = True
runner._shutdown_event = asyncio.Event()
runner._exit_reason = None
runner._pending_messages = {"session": "pending text"}
runner._pending_approvals = {"session": {"command": "rm -rf /tmp/x"}}
runner._restart_drain_timeout = 0.0
runner._background_tasks = set()
runner._shutdown_all_gateway_honcho = lambda: None
adapter = StubAdapter()
release = asyncio.Event()
async def block_forever(_event):
@@ -49,7 +83,7 @@ async def test_gateway_stop_interrupts_running_agents_and_cancels_adapter_tasks(
return None
adapter.set_message_handler(block_forever)
event = MessageEvent(text="work", source=make_restart_source(), message_id="1")
event = MessageEvent(text="work", source=_source(), message_id="1")
await adapter.handle_message(event)
await asyncio.sleep(0)
@@ -59,6 +93,7 @@ async def test_gateway_stop_interrupts_running_agents_and_cancels_adapter_tasks(
session_key = build_session_key(event.source)
running_agent = MagicMock()
runner._running_agents = {session_key: running_agent}
runner.adapters = {Platform.TELEGRAM: adapter}
with patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"):
await runner.stop()
@@ -70,78 +105,3 @@ async def test_gateway_stop_interrupts_running_agents_and_cancels_adapter_tasks(
assert runner._pending_messages == {}
assert runner._pending_approvals == {}
assert runner._shutdown_event.is_set() is True
@pytest.mark.asyncio
async def test_gateway_stop_drains_running_agents_before_disconnect():
runner, adapter = make_restart_runner()
disconnect_mock = AsyncMock()
adapter.disconnect = disconnect_mock
running_agent = MagicMock()
runner._running_agents = {"session": running_agent}
async def finish_agent():
await asyncio.sleep(0.05)
runner._running_agents.clear()
asyncio.create_task(finish_agent())
with patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"):
await runner.stop()
running_agent.interrupt.assert_not_called()
disconnect_mock.assert_awaited_once()
assert runner._shutdown_event.is_set() is True
@pytest.mark.asyncio
async def test_gateway_stop_interrupts_after_drain_timeout():
runner, adapter = make_restart_runner()
runner._restart_drain_timeout = 0.05
disconnect_mock = AsyncMock()
adapter.disconnect = disconnect_mock
running_agent = MagicMock()
runner._running_agents = {"session": running_agent}
with patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"):
await runner.stop()
running_agent.interrupt.assert_called_once_with("Gateway shutting down")
disconnect_mock.assert_awaited_once()
assert runner._shutdown_event.is_set() is True
@pytest.mark.asyncio
async def test_gateway_stop_service_restart_sets_named_exit_code():
runner, adapter = make_restart_runner()
adapter.disconnect = AsyncMock()
with patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"):
await runner.stop(restart=True, service_restart=True)
assert runner._exit_code == GATEWAY_SERVICE_RESTART_EXIT_CODE
@pytest.mark.asyncio
async def test_drain_active_agents_throttles_status_updates():
runner, _adapter = make_restart_runner()
runner._update_runtime_status = MagicMock()
runner._running_agents = {"a": MagicMock(), "b": MagicMock()}
async def finish_agents():
await asyncio.sleep(0.12)
runner._running_agents.pop("a")
await asyncio.sleep(0.12)
runner._running_agents.clear()
task = asyncio.create_task(finish_agents())
await runner._drain_active_agents(1.0)
await task
# Start, one count-change update, and final update. Allow one extra update
# if the loop observes the zero-agent state before exiting.
assert 3 <= runner._update_runtime_status.call_count <= 4
File diff suppressed because it is too large Load Diff
+64 -152
View File
@@ -11,10 +11,24 @@ import pytest
from gateway.config import PlatformConfig
# The matrix adapter module is importable without mautrix installed
# (module-level imports use try/except with stubs). No need for
# module-level mock installation — tests that call adapter methods
# needing real mautrix APIs mock them individually.
def _ensure_nio_mock():
"""Install a mock nio module when matrix-nio isn't available."""
if "nio" in sys.modules and hasattr(sys.modules["nio"], "__file__"):
return
nio_mod = MagicMock()
nio_mod.MegolmEvent = type("MegolmEvent", (), {})
nio_mod.RoomMessageText = type("RoomMessageText", (), {})
nio_mod.RoomMessageImage = type("RoomMessageImage", (), {})
nio_mod.RoomMessageAudio = type("RoomMessageAudio", (), {})
nio_mod.RoomMessageVideo = type("RoomMessageVideo", (), {})
nio_mod.RoomMessageFile = type("RoomMessageFile", (), {})
nio_mod.DownloadResponse = type("DownloadResponse", (), {})
nio_mod.MemoryDownloadResponse = type("MemoryDownloadResponse", (), {})
nio_mod.InviteMemberEvent = type("InviteMemberEvent", (), {})
sys.modules.setdefault("nio", nio_mod)
_ensure_nio_mock()
def _make_adapter(tmp_path=None):
@@ -36,25 +50,24 @@ def _make_adapter(tmp_path=None):
return adapter
def _set_dm(adapter, room_id="!room1:example.org", is_dm=True):
"""Mark a room as DM (or not) in the adapter's cache."""
adapter._dm_rooms[room_id] = is_dm
def _make_room(room_id="!room1:example.org", member_count=5, is_dm=False):
"""Create a fake Matrix room."""
room = SimpleNamespace(
room_id=room_id,
member_count=member_count,
users={},
)
return room
def _make_event(
body,
sender="@alice:example.org",
event_id="$evt1",
room_id="!room1:example.org",
formatted_body=None,
thread_id=None,
):
"""Create a fake room message event.
The mautrix adapter reads ``event.room_id``, ``event.sender``,
``event.event_id``, ``event.timestamp``, and ``event.content``
(a dict with ``msgtype``, ``body``, etc.).
"""
"""Create a fake RoomMessageText event."""
content = {"body": body, "msgtype": "m.text"}
if formatted_body:
content["formatted_body"] = formatted_body
@@ -70,9 +83,9 @@ def _make_event(
return SimpleNamespace(
sender=sender,
event_id=event_id,
room_id=room_id,
timestamp=int(time.time() * 1000),
content=content,
server_timestamp=int(time.time() * 1000),
body=body,
source={"content": content},
)
@@ -139,9 +152,10 @@ async def test_require_mention_default_ignores_unmentioned(monkeypatch):
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
adapter = _make_adapter()
room = _make_room()
event = _make_event("hello everyone")
await adapter._on_room_message(event)
await adapter._on_room_message(room, event)
adapter.handle_message.assert_not_awaited()
@@ -153,9 +167,10 @@ async def test_require_mention_default_processes_mentioned(monkeypatch):
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
adapter = _make_adapter()
room = _make_room()
event = _make_event("@hermes:example.org help me")
await adapter._on_room_message(event)
await adapter._on_room_message(room, event)
adapter.handle_message.assert_awaited_once()
msg = adapter.handle_message.await_args.args[0]
assert msg.text == "help me"
@@ -169,10 +184,11 @@ async def test_require_mention_html_pill(monkeypatch):
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
adapter = _make_adapter()
room = _make_room()
formatted = '<a href="https://matrix.to/#/@hermes:example.org">Hermes</a> help'
event = _make_event("Hermes help", formatted_body=formatted)
await adapter._on_room_message(event)
await adapter._on_room_message(room, event)
adapter.handle_message.assert_awaited_once()
@@ -184,11 +200,11 @@ async def test_require_mention_dm_always_responds(monkeypatch):
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
adapter = _make_adapter()
# Mark the room as a DM via the adapter's cache.
_set_dm(adapter)
# member_count=2 triggers DM detection
room = _make_room(member_count=2)
event = _make_event("hello without mention")
await adapter._on_room_message(event)
await adapter._on_room_message(room, event)
adapter.handle_message.assert_awaited_once()
@@ -200,10 +216,10 @@ async def test_dm_strips_mention(monkeypatch):
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
adapter = _make_adapter()
_set_dm(adapter)
room = _make_room(member_count=2)
event = _make_event("@hermes:example.org help me")
await adapter._on_room_message(event)
await adapter._on_room_message(room, event)
adapter.handle_message.assert_awaited_once()
msg = adapter.handle_message.await_args.args[0]
assert msg.text == "help me"
@@ -217,9 +233,10 @@ async def test_bare_mention_passes_empty_string(monkeypatch):
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
adapter = _make_adapter()
room = _make_room()
event = _make_event("@hermes:example.org")
await adapter._on_room_message(event)
await adapter._on_room_message(room, event)
adapter.handle_message.assert_awaited_once()
msg = adapter.handle_message.await_args.args[0]
assert msg.text == ""
@@ -233,9 +250,10 @@ async def test_require_mention_free_response_room(monkeypatch):
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
adapter = _make_adapter()
event = _make_event("hello without mention", room_id="!room1:example.org")
room = _make_room(room_id="!room1:example.org")
event = _make_event("hello without mention")
await adapter._on_room_message(event)
await adapter._on_room_message(room, event)
adapter.handle_message.assert_awaited_once()
@@ -249,9 +267,10 @@ async def test_require_mention_bot_participated_thread(monkeypatch):
adapter = _make_adapter()
adapter._bot_participated_threads.add("$thread1")
room = _make_room()
event = _make_event("hello without mention", thread_id="$thread1")
await adapter._on_room_message(event)
await adapter._on_room_message(room, event)
adapter.handle_message.assert_awaited_once()
@@ -263,9 +282,10 @@ async def test_require_mention_disabled(monkeypatch):
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
adapter = _make_adapter()
room = _make_room()
event = _make_event("hello without mention")
await adapter._on_room_message(event)
await adapter._on_room_message(room, event)
adapter.handle_message.assert_awaited_once()
msg = adapter.handle_message.await_args.args[0]
assert msg.text == "hello without mention"
@@ -283,9 +303,10 @@ async def test_auto_thread_default_creates_thread(monkeypatch):
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
adapter = _make_adapter()
room = _make_room()
event = _make_event("hello", event_id="$msg1")
await adapter._on_room_message(event)
await adapter._on_room_message(room, event)
adapter.handle_message.assert_awaited_once()
msg = adapter.handle_message.await_args.args[0]
assert msg.source.thread_id == "$msg1"
@@ -299,9 +320,10 @@ async def test_auto_thread_preserves_existing_thread(monkeypatch):
adapter = _make_adapter()
adapter._bot_participated_threads.add("$thread_root")
room = _make_room()
event = _make_event("reply in thread", thread_id="$thread_root")
await adapter._on_room_message(event)
await adapter._on_room_message(room, event)
adapter.handle_message.assert_awaited_once()
msg = adapter.handle_message.await_args.args[0]
assert msg.source.thread_id == "$thread_root"
@@ -314,10 +336,10 @@ async def test_auto_thread_skips_dm(monkeypatch):
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
adapter = _make_adapter()
_set_dm(adapter)
room = _make_room(member_count=2)
event = _make_event("hello dm", event_id="$dm1")
await adapter._on_room_message(event)
await adapter._on_room_message(room, event)
adapter.handle_message.assert_awaited_once()
msg = adapter.handle_message.await_args.args[0]
assert msg.source.thread_id is None
@@ -330,9 +352,10 @@ async def test_auto_thread_disabled(monkeypatch):
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
adapter = _make_adapter()
room = _make_room()
event = _make_event("hello", event_id="$msg1")
await adapter._on_room_message(event)
await adapter._on_room_message(room, event)
adapter.handle_message.assert_awaited_once()
msg = adapter.handle_message.await_args.args[0]
assert msg.source.thread_id is None
@@ -345,10 +368,11 @@ async def test_auto_thread_tracks_participation(monkeypatch):
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
adapter = _make_adapter()
room = _make_room()
event = _make_event("hello", event_id="$msg1")
with patch.object(adapter, "_save_participated_threads"):
await adapter._on_room_message(event)
await adapter._on_room_message(room, event)
assert "$msg1" in adapter._bot_participated_threads
@@ -361,9 +385,8 @@ async def test_auto_thread_tracks_participation(monkeypatch):
class TestThreadPersistence:
def test_empty_state_file(self, tmp_path, monkeypatch):
"""No state file → empty set."""
from gateway.platforms.matrix import MatrixAdapter
monkeypatch.setattr(
MatrixAdapter, "_thread_state_path",
"gateway.platforms.matrix.MatrixAdapter._thread_state_path",
staticmethod(lambda: tmp_path / "matrix_threads.json"),
)
adapter = _make_adapter()
@@ -372,10 +395,9 @@ class TestThreadPersistence:
def test_track_thread_persists(self, tmp_path, monkeypatch):
"""_track_thread writes to disk."""
from gateway.platforms.matrix import MatrixAdapter
state_path = tmp_path / "matrix_threads.json"
monkeypatch.setattr(
MatrixAdapter, "_thread_state_path",
"gateway.platforms.matrix.MatrixAdapter._thread_state_path",
staticmethod(lambda: state_path),
)
adapter = _make_adapter()
@@ -386,11 +408,10 @@ class TestThreadPersistence:
def test_threads_survive_reload(self, tmp_path, monkeypatch):
"""Persisted threads are loaded by a new adapter instance."""
from gateway.platforms.matrix import MatrixAdapter
state_path = tmp_path / "matrix_threads.json"
state_path.write_text(json.dumps(["$t1", "$t2"]))
monkeypatch.setattr(
MatrixAdapter, "_thread_state_path",
"gateway.platforms.matrix.MatrixAdapter._thread_state_path",
staticmethod(lambda: state_path),
)
adapter = _make_adapter()
@@ -399,10 +420,9 @@ class TestThreadPersistence:
def test_cap_max_tracked_threads(self, tmp_path, monkeypatch):
"""Thread set is trimmed to _MAX_TRACKED_THREADS."""
from gateway.platforms.matrix import MatrixAdapter
state_path = tmp_path / "matrix_threads.json"
monkeypatch.setattr(
MatrixAdapter, "_thread_state_path",
"gateway.platforms.matrix.MatrixAdapter._thread_state_path",
staticmethod(lambda: state_path),
)
adapter = _make_adapter()
@@ -416,95 +436,6 @@ class TestThreadPersistence:
assert len(data) == 5
# ---------------------------------------------------------------------------
# DM mention-thread feature
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_dm_mention_thread_disabled_by_default(monkeypatch):
"""Default (dm_mention_threads=false): DM with mention should NOT create a thread."""
monkeypatch.delenv("MATRIX_DM_MENTION_THREADS", raising=False)
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
adapter = _make_adapter()
_set_dm(adapter)
event = _make_event("@hermes:example.org help me", event_id="$dm1")
await adapter._on_room_message(event)
adapter.handle_message.assert_awaited_once()
msg = adapter.handle_message.await_args.args[0]
assert msg.source.thread_id is None
@pytest.mark.asyncio
async def test_dm_mention_thread_creates_thread(monkeypatch):
"""MATRIX_DM_MENTION_THREADS=true: DM with @mention creates a thread."""
monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
adapter = _make_adapter()
_set_dm(adapter)
event = _make_event("@hermes:example.org help me", event_id="$dm1")
with patch.object(adapter, "_save_participated_threads"):
await adapter._on_room_message(event)
adapter.handle_message.assert_awaited_once()
msg = adapter.handle_message.await_args.args[0]
assert msg.source.thread_id == "$dm1"
assert msg.text == "help me"
@pytest.mark.asyncio
async def test_dm_mention_thread_no_mention_no_thread(monkeypatch):
"""MATRIX_DM_MENTION_THREADS=true: DM without mention does NOT create a thread."""
monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
adapter = _make_adapter()
_set_dm(adapter)
event = _make_event("hello without mention", event_id="$dm1")
await adapter._on_room_message(event)
adapter.handle_message.assert_awaited_once()
msg = adapter.handle_message.await_args.args[0]
assert msg.source.thread_id is None
@pytest.mark.asyncio
async def test_dm_mention_thread_preserves_existing_thread(monkeypatch):
"""MATRIX_DM_MENTION_THREADS=true: DM already in a thread keeps that thread_id."""
monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
adapter = _make_adapter()
_set_dm(adapter)
adapter._bot_participated_threads.add("$existing_thread")
event = _make_event("@hermes:example.org help me", thread_id="$existing_thread")
await adapter._on_room_message(event)
adapter.handle_message.assert_awaited_once()
msg = adapter.handle_message.await_args.args[0]
assert msg.source.thread_id == "$existing_thread"
@pytest.mark.asyncio
async def test_dm_mention_thread_tracks_participation(monkeypatch):
"""DM mention-thread tracks the thread in _bot_participated_threads."""
monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
adapter = _make_adapter()
_set_dm(adapter)
event = _make_event("@hermes:example.org help", event_id="$dm1")
with patch.object(adapter, "_save_participated_threads"):
await adapter._on_room_message(event)
assert "$dm1" in adapter._bot_participated_threads
# ---------------------------------------------------------------------------
# YAML config bridge
# ---------------------------------------------------------------------------
@@ -549,25 +480,6 @@ class TestMatrixConfigBridge:
assert os.getenv("MATRIX_FREE_RESPONSE_ROOMS") == "!room1:example.org,!room2:example.org"
assert os.getenv("MATRIX_AUTO_THREAD") == "false"
def test_yaml_bridge_sets_dm_mention_threads(self, monkeypatch, tmp_path):
"""Matrix YAML dm_mention_threads should bridge to env var."""
monkeypatch.delenv("MATRIX_DM_MENTION_THREADS", raising=False)
import os
import yaml
yaml_content = {"matrix": {"dm_mention_threads": True}}
config_file = tmp_path / "config.yaml"
config_file.write_text(yaml.dump(yaml_content))
yaml_cfg = yaml.safe_load(config_file.read_text())
matrix_cfg = yaml_cfg.get("matrix", {})
if isinstance(matrix_cfg, dict):
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", str(matrix_cfg["dm_mention_threads"]).lower())
assert os.getenv("MATRIX_DM_MENTION_THREADS") == "true"
def test_env_vars_take_precedence_over_yaml(self, monkeypatch):
"""Env vars should not be overwritten by YAML values."""
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "true")
+129 -109
View File
@@ -1,23 +1,18 @@
"""Tests for Matrix voice message support (MSC3245).
Updated for the mautrix-python SDK (no more matrix-nio / nio imports).
"""
"""Tests for Matrix voice message support (MSC3245)."""
import io
import os
import tempfile
import types
from types import SimpleNamespace
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
# Try importing mautrix; skip entire file if not available.
# Try importing real nio; skip entire file if not available.
# A MagicMock in sys.modules (from another test) is not the real package.
try:
import mautrix as _mautrix_probe
if not isinstance(_mautrix_probe, types.ModuleType) or not hasattr(_mautrix_probe, "__file__"):
pytest.skip("mautrix in sys.modules is a mock, not the real package", allow_module_level=True)
import nio as _nio_probe
if not isinstance(_nio_probe, types.ModuleType) or not hasattr(_nio_probe, "__file__"):
pytest.skip("nio in sys.modules is a mock, not the real package", allow_module_level=True)
except ImportError:
pytest.skip("mautrix not installed", allow_module_level=True)
pytest.skip("matrix-nio not installed", allow_module_level=True)
from gateway.platforms.base import MessageType
@@ -30,7 +25,7 @@ def _make_adapter():
"""Create a MatrixAdapter with mocked config."""
from gateway.platforms.matrix import MatrixAdapter
from gateway.config import PlatformConfig
config = PlatformConfig(
enabled=True,
token="***",
@@ -43,26 +38,32 @@ def _make_adapter():
return adapter
def _make_room(room_id: str = "!test:example.org", member_count: int = 2):
"""Create a mock Matrix room."""
room = MagicMock()
room.room_id = room_id
room.member_count = member_count
return room
def _make_audio_event(
event_id: str = "$audio_event",
sender: str = "@alice:example.org",
room_id: str = "!test:example.org",
body: str = "Voice message",
url: str = "mxc://example.org/abc123",
is_voice: bool = False,
mimetype: str = "audio/ogg",
timestamp: int = 9999999999000, # ms
timestamp: float = 9999999999000, # ms
):
"""
Create a mock mautrix room message event.
In mautrix, the handler receives a single event object with attributes
``room_id``, ``sender``, ``event_id``, ``timestamp``, and ``content``
(a dict-like or serializable object).
Create a mock RoomMessageAudio event that passes isinstance checks.
Args:
is_voice: If True, adds org.matrix.msc3245.voice field to content.
is_voice: If True, adds org.matrix.msc3245.voice field to content
"""
import nio
# Build the source dict that nio events expose via .source
content = {
"msgtype": "m.audio",
"body": body,
@@ -71,35 +72,39 @@ def _make_audio_event(
"mimetype": mimetype,
},
}
if is_voice:
content["org.matrix.msc3245.voice"] = {}
event = SimpleNamespace(
event_id=event_id,
sender=sender,
room_id=room_id,
timestamp=timestamp,
content=content,
)
# Create a real nio RoomMessageAudio-like object
# We use MagicMock but configure __class__ to pass isinstance check
event = MagicMock(spec=nio.RoomMessageAudio)
event.event_id = event_id
event.sender = sender
event.body = body
event.url = url
event.server_timestamp = timestamp
event.source = {
"type": "m.room.message",
"content": content,
}
# For MIME type extraction - needs to be a dict
event.content = content
return event
def _make_state_store(member_count: int = 2):
"""Create a mock state store with get_members/get_member support."""
store = MagicMock()
# get_members returns a list of member user IDs
members = [MagicMock() for _ in range(member_count)]
store.get_members = AsyncMock(return_value=members)
# get_member returns a single member info object
member = MagicMock()
member.displayname = "Alice"
store.get_member = AsyncMock(return_value=member)
return store
def _make_download_response(body: bytes = b"fake audio data"):
"""Create a mock nio.MemoryDownloadResponse."""
import nio
resp = MagicMock()
resp.body = body
resp.__class__ = nio.MemoryDownloadResponse
return resp
# ---------------------------------------------------------------------------
# Tests: MSC3245 Voice Detection
# Tests: MSC3245 Voice Detection (RED -> GREEN)
# ---------------------------------------------------------------------------
class TestMatrixVoiceMessageDetection:
@@ -113,28 +118,27 @@ class TestMatrixVoiceMessageDetection:
self.adapter._message_handler = AsyncMock()
# Mock _mxc_to_http to return a fake HTTP URL
self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}"
# Mock client for authenticated download — download_media returns bytes directly
# Mock client for authenticated download
self.adapter._client = MagicMock()
self.adapter._client.download_media = AsyncMock(return_value=b"fake audio data")
# State store for DM detection
self.adapter._client.state_store = _make_state_store()
self.adapter._client.download = AsyncMock(return_value=_make_download_response())
@pytest.mark.asyncio
async def test_voice_message_has_type_voice(self):
"""Voice messages (with MSC3245 field) should be MessageType.VOICE."""
room = _make_room()
event = _make_audio_event(is_voice=True)
# Capture the MessageEvent passed to handle_message
captured_event = None
async def capture(msg_event):
nonlocal captured_event
captured_event = msg_event
self.adapter.handle_message = capture
await self.adapter._on_room_message(event)
await self.adapter._on_room_message_media(room, event)
assert captured_event is not None, "No event was captured"
assert captured_event.message_type == MessageType.VOICE, \
f"Expected MessageType.VOICE, got {captured_event.message_type}"
@@ -142,43 +146,44 @@ class TestMatrixVoiceMessageDetection:
@pytest.mark.asyncio
async def test_voice_message_has_local_path(self):
"""Voice messages should have a local cached path in media_urls."""
room = _make_room()
event = _make_audio_event(is_voice=True)
captured_event = None
async def capture(msg_event):
nonlocal captured_event
captured_event = msg_event
self.adapter.handle_message = capture
await self.adapter._on_room_message(event)
await self.adapter._on_room_message_media(room, event)
assert captured_event is not None
assert captured_event.media_urls is not None
assert len(captured_event.media_urls) > 0
# Should be a local path, not an HTTP URL
assert not captured_event.media_urls[0].startswith("http"), \
f"media_urls should contain local path, got {captured_event.media_urls[0]}"
# download_media is called with a ContentURI wrapping the mxc URL
self.adapter._client.download_media.assert_awaited_once()
self.adapter._client.download.assert_awaited_once_with(mxc=event.url)
assert captured_event.media_types == ["audio/ogg"]
@pytest.mark.asyncio
async def test_audio_without_msc3245_stays_audio_type(self):
"""Regular audio uploads (no MSC3245 field) should remain MessageType.AUDIO."""
room = _make_room()
event = _make_audio_event(is_voice=False) # NOT a voice message
captured_event = None
async def capture(msg_event):
nonlocal captured_event
captured_event = msg_event
self.adapter.handle_message = capture
await self.adapter._on_room_message(event)
await self.adapter._on_room_message_media(room, event)
assert captured_event is not None
assert captured_event.message_type == MessageType.AUDIO, \
f"Expected MessageType.AUDIO for non-voice, got {captured_event.message_type}"
@@ -186,24 +191,25 @@ class TestMatrixVoiceMessageDetection:
@pytest.mark.asyncio
async def test_regular_audio_has_http_url(self):
"""Regular audio uploads should keep HTTP URL (not cached locally)."""
room = _make_room()
event = _make_audio_event(is_voice=False)
captured_event = None
async def capture(msg_event):
nonlocal captured_event
captured_event = msg_event
self.adapter.handle_message = capture
await self.adapter._on_room_message(event)
await self.adapter._on_room_message_media(room, event)
assert captured_event is not None
assert captured_event.media_urls is not None
# Should be HTTP URL, not local path
assert captured_event.media_urls[0].startswith("http"), \
f"Non-voice audio should have HTTP URL, got {captured_event.media_urls[0]}"
self.adapter._client.download_media.assert_not_awaited()
self.adapter._client.download.assert_not_awaited()
assert captured_event.media_types == ["audio/ogg"]
@@ -218,26 +224,29 @@ class TestMatrixVoiceCacheFallback:
self.adapter._message_handler = AsyncMock()
self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}"
self.adapter._client = MagicMock()
self.adapter._client.state_store = _make_state_store()
@pytest.mark.asyncio
async def test_voice_cache_failure_falls_back_to_http_url(self):
"""If caching fails (download returns None), voice message should still be delivered with HTTP URL."""
"""If caching fails, voice message should still be delivered with HTTP URL."""
room = _make_room()
event = _make_audio_event(is_voice=True)
# download_media returns None on failure
self.adapter._client.download_media = AsyncMock(return_value=None)
# Make download fail
import nio
error_resp = MagicMock()
error_resp.__class__ = nio.DownloadError
self.adapter._client.download = AsyncMock(return_value=error_resp)
captured_event = None
async def capture(msg_event):
nonlocal captured_event
captured_event = msg_event
self.adapter.handle_message = capture
await self.adapter._on_room_message(event)
await self.adapter._on_room_message_media(room, event)
assert captured_event is not None
assert captured_event.media_urls is not None
# Should fall back to HTTP URL
@@ -247,9 +256,10 @@ class TestMatrixVoiceCacheFallback:
@pytest.mark.asyncio
async def test_voice_cache_exception_falls_back_to_http_url(self):
"""Unexpected download exceptions should also fall back to HTTP URL."""
room = _make_room()
event = _make_audio_event(is_voice=True)
self.adapter._client.download_media = AsyncMock(side_effect=RuntimeError("boom"))
self.adapter._client.download = AsyncMock(side_effect=RuntimeError("boom"))
captured_event = None
@@ -259,7 +269,7 @@ class TestMatrixVoiceCacheFallback:
self.adapter.handle_message = capture
await self.adapter._on_room_message(event)
await self.adapter._on_room_message_media(room, event)
assert captured_event is not None
assert captured_event.media_urls is not None
@@ -268,7 +278,7 @@ class TestMatrixVoiceCacheFallback:
# ---------------------------------------------------------------------------
# Tests: send_voice includes MSC3245 field
# Tests: send_voice includes MSC3245 field (RED -> GREEN)
# ---------------------------------------------------------------------------
class TestMatrixSendVoiceMSC3245:
@@ -277,52 +287,62 @@ class TestMatrixSendVoiceMSC3245:
def setup_method(self):
self.adapter = _make_adapter()
self.adapter._user_id = "@bot:example.org"
# Mock client — upload_media returns a ContentURI string
# Mock client with successful upload
self.adapter._client = MagicMock()
self.upload_call = None
async def mock_upload_media(data, mime_type=None, filename=None, **kwargs):
self.upload_call = {"data": data, "mime_type": mime_type, "filename": filename}
return "mxc://example.org/uploaded"
async def mock_upload(*args, **kwargs):
self.upload_call = (args, kwargs)
import nio
resp = MagicMock()
resp.content_uri = "mxc://example.org/uploaded"
resp.__class__ = nio.UploadResponse
return resp, None
self.adapter._client.upload_media = mock_upload_media
self.adapter._client.upload = mock_upload
@pytest.mark.asyncio
@patch("mimetypes.guess_type", return_value=("audio/ogg", None))
async def test_send_voice_includes_msc3245_field(self, _mock_guess):
async def test_send_voice_includes_msc3245_field(self):
"""send_voice should include org.matrix.msc3245.voice in message content."""
import tempfile
import os
# Create a temp audio file
with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
f.write(b"fake audio data")
temp_path = f.name
try:
# Capture the message content sent via send_message_event
# Capture the message content sent to room_send
sent_content = None
async def mock_send_message_event(room_id, event_type, content):
async def mock_room_send(room_id, event_type, content):
nonlocal sent_content
sent_content = content
# send_message_event returns an EventID string
return "$sent_event"
self.adapter._client.send_message_event = mock_send_message_event
resp = MagicMock()
resp.event_id = "$sent_event"
import nio
resp.__class__ = nio.RoomSendResponse
return resp
self.adapter._client.room_send = mock_room_send
await self.adapter.send_voice(
chat_id="!room:example.org",
audio_path=temp_path,
caption="Test voice",
)
assert sent_content is not None, "No message was sent"
assert "org.matrix.msc3245.voice" in sent_content, \
f"MSC3245 voice field missing from content: {sent_content.keys()}"
assert sent_content["msgtype"] == "m.audio"
assert sent_content["info"]["mimetype"] == "audio/ogg"
assert self.upload_call is not None, "Expected upload_media() to be called"
assert isinstance(self.upload_call["data"], bytes)
assert self.upload_call["mime_type"] == "audio/ogg"
assert self.upload_call["filename"].endswith(".ogg")
assert self.upload_call is not None, "Expected upload() to be called"
args, kwargs = self.upload_call
assert isinstance(args[0], io.BytesIO)
assert kwargs["content_type"] == "audio/ogg"
assert kwargs["filename"].endswith(".ogg")
finally:
os.unlink(temp_path)
-160
View File
@@ -1,160 +0,0 @@
import asyncio
import shutil
import subprocess
from unittest.mock import AsyncMock, MagicMock
import pytest
import gateway.run as gateway_run
from gateway.platforms.base import MessageEvent, MessageType
from gateway.restart import DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
from gateway.session import build_session_key
from tests.gateway.restart_test_helpers import make_restart_runner, make_restart_source
@pytest.mark.asyncio
async def test_restart_command_while_busy_requests_drain_without_interrupt():
runner, _adapter = make_restart_runner()
runner.request_restart = MagicMock(return_value=True)
event = MessageEvent(
text="/restart",
message_type=MessageType.TEXT,
source=make_restart_source(),
message_id="m1",
)
session_key = build_session_key(event.source)
running_agent = MagicMock()
runner._running_agents[session_key] = running_agent
result = await runner._handle_message(event)
assert result == "⏳ Draining 1 active agent(s) before restart..."
running_agent.interrupt.assert_not_called()
runner.request_restart.assert_called_once_with(detached=True, via_service=False)
@pytest.mark.asyncio
async def test_drain_queue_mode_queues_follow_up_without_interrupt():
runner, adapter = make_restart_runner()
runner._draining = True
runner._restart_requested = True
runner._busy_input_mode = "queue"
event = MessageEvent(
text="follow up",
message_type=MessageType.TEXT,
source=make_restart_source(),
message_id="m2",
)
session_key = build_session_key(event.source)
adapter._active_sessions[session_key] = asyncio.Event()
await adapter.handle_message(event)
assert session_key in adapter._pending_messages
assert adapter._pending_messages[session_key].text == "follow up"
assert not adapter._active_sessions[session_key].is_set()
assert any("queued for the next turn" in message for message in adapter.sent)
@pytest.mark.asyncio
async def test_draining_rejects_new_session_messages():
runner, _adapter = make_restart_runner()
runner._draining = True
runner._restart_requested = True
event = MessageEvent(
text="hello",
message_type=MessageType.TEXT,
source=make_restart_source("fresh"),
message_id="m3",
)
result = await runner._handle_message(event)
assert result == "⏳ Gateway is restarting and is not accepting new work right now."
def test_load_busy_input_mode_prefers_env_then_config_then_default(tmp_path, monkeypatch):
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.delenv("HERMES_GATEWAY_BUSY_INPUT_MODE", raising=False)
assert gateway_run.GatewayRunner._load_busy_input_mode() == "interrupt"
(tmp_path / "config.yaml").write_text(
"display:\n busy_input_mode: queue\n", encoding="utf-8"
)
assert gateway_run.GatewayRunner._load_busy_input_mode() == "queue"
monkeypatch.setenv("HERMES_GATEWAY_BUSY_INPUT_MODE", "interrupt")
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
):
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.delenv("HERMES_RESTART_DRAIN_TIMEOUT", raising=False)
assert (
gateway_run.GatewayRunner._load_restart_drain_timeout()
== DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
)
(tmp_path / "config.yaml").write_text(
"agent:\n restart_drain_timeout: 12\n", encoding="utf-8"
)
assert gateway_run.GatewayRunner._load_restart_drain_timeout() == 12.0
monkeypatch.setenv("HERMES_RESTART_DRAIN_TIMEOUT", "7")
assert gateway_run.GatewayRunner._load_restart_drain_timeout() == 7.0
monkeypatch.setenv("HERMES_RESTART_DRAIN_TIMEOUT", "invalid")
assert (
gateway_run.GatewayRunner._load_restart_drain_timeout()
== DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
)
assert "Invalid restart_drain_timeout" in caplog.text
@pytest.mark.asyncio
async def test_request_restart_is_idempotent():
runner, _adapter = make_restart_runner()
runner.stop = AsyncMock()
assert runner.request_restart(detached=True, via_service=False) is True
first_task = next(iter(runner._background_tasks))
assert runner.request_restart(detached=True, via_service=False) is False
await first_task
runner.stop.assert_awaited_once_with(
restart=True, detached_restart=True, service_restart=False
)
@pytest.mark.asyncio
async def test_launch_detached_restart_command_uses_setsid(monkeypatch):
runner, _adapter = make_restart_runner()
popen_calls = []
monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["/usr/bin/hermes"])
monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321)
monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/setsid" if cmd == "setsid" else None)
def fake_popen(cmd, **kwargs):
popen_calls.append((cmd, kwargs))
return MagicMock()
monkeypatch.setattr(subprocess, "Popen", fake_popen)
await runner._launch_detached_restart_command()
assert len(popen_calls) == 1
cmd, kwargs = popen_calls[0]
assert cmd[:2] == ["/usr/bin/setsid", "bash"]
assert "gateway restart" in cmd[-1]
assert "kill -0 321" in cmd[-1]
assert kwargs["start_new_session"] is True
assert kwargs["stdout"] is subprocess.DEVNULL
assert kwargs["stderr"] is subprocess.DEVNULL
-1
View File
@@ -221,6 +221,5 @@ class TestHandleResumeCommand:
runner._async_flush_memories.assert_called_once_with(
"current_session_001",
"agent:main:telegram:dm:67890",
)
db.close()
@@ -127,16 +127,6 @@ async def test_shutdown_fires_finalize_for_active_agents(mock_invoke_hook):
runner._shutdown_event = MagicMock()
runner.adapters = {}
runner._exit_reason = "test"
runner._exit_code = None
runner._draining = False
runner._restart_requested = False
runner._restart_task_started = False
runner._restart_detached = False
runner._restart_via_service = False
runner._restart_drain_timeout = 0.0
runner._stop_task = None
runner._running_agents_ts = {}
runner._update_runtime_status = MagicMock()
agent1 = MagicMock()
agent1.session_id = "sess-a"
+19 -93
View File
@@ -3,15 +3,9 @@ import os
from gateway.config import Platform
from gateway.run import GatewayRunner
from gateway.session import SessionContext, SessionSource
from gateway.session_context import (
get_session_env,
set_session_vars,
clear_session_vars,
)
def test_set_session_env_sets_contextvars(monkeypatch):
"""_set_session_env should populate contextvars, not os.environ."""
def test_set_session_env_includes_thread_id(monkeypatch):
runner = object.__new__(GatewayRunner)
source = SessionSource(
platform=Platform.TELEGRAM,
@@ -27,93 +21,25 @@ def test_set_session_env_sets_contextvars(monkeypatch):
monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False)
monkeypatch.delenv("HERMES_SESSION_THREAD_ID", raising=False)
tokens = runner._set_session_env(context)
runner._set_session_env(context)
# Values should be readable via get_session_env (contextvar path)
assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram"
assert get_session_env("HERMES_SESSION_CHAT_ID") == "-1001"
assert get_session_env("HERMES_SESSION_CHAT_NAME") == "Group"
assert get_session_env("HERMES_SESSION_THREAD_ID") == "17585"
assert os.getenv("HERMES_SESSION_PLATFORM") == "telegram"
assert os.getenv("HERMES_SESSION_CHAT_ID") == "-1001"
assert os.getenv("HERMES_SESSION_CHAT_NAME") == "Group"
assert os.getenv("HERMES_SESSION_THREAD_ID") == "17585"
def test_clear_session_env_removes_thread_id(monkeypatch):
runner = object.__new__(GatewayRunner)
monkeypatch.setenv("HERMES_SESSION_PLATFORM", "telegram")
monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "-1001")
monkeypatch.setenv("HERMES_SESSION_CHAT_NAME", "Group")
monkeypatch.setenv("HERMES_SESSION_THREAD_ID", "17585")
runner._clear_session_env()
# os.environ should NOT be touched
assert os.getenv("HERMES_SESSION_PLATFORM") is None
assert os.getenv("HERMES_SESSION_CHAT_ID") is None
assert os.getenv("HERMES_SESSION_CHAT_NAME") is None
assert os.getenv("HERMES_SESSION_THREAD_ID") is None
# Clean up
runner._clear_session_env(tokens)
def test_clear_session_env_restores_previous_state(monkeypatch):
"""_clear_session_env should restore contextvars to their pre-handler values."""
runner = object.__new__(GatewayRunner)
monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False)
monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False)
monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False)
monkeypatch.delenv("HERMES_SESSION_THREAD_ID", raising=False)
source = SessionSource(
platform=Platform.TELEGRAM,
chat_id="-1001",
chat_name="Group",
chat_type="group",
thread_id="17585",
)
context = SessionContext(source=source, connected_platforms=[], home_channels={})
tokens = runner._set_session_env(context)
assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram"
runner._clear_session_env(tokens)
# After clear, contextvars should return to defaults (empty)
assert get_session_env("HERMES_SESSION_PLATFORM") == ""
assert get_session_env("HERMES_SESSION_CHAT_ID") == ""
assert get_session_env("HERMES_SESSION_CHAT_NAME") == ""
assert get_session_env("HERMES_SESSION_THREAD_ID") == ""
def test_get_session_env_falls_back_to_os_environ(monkeypatch):
"""get_session_env should fall back to os.environ when contextvar is unset."""
monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord")
# No contextvar set — should read from os.environ
assert get_session_env("HERMES_SESSION_PLATFORM") == "discord"
# Now set a contextvar — should prefer it
tokens = set_session_vars(platform="telegram")
assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram"
# Restore — should fall back to os.environ again
clear_session_vars(tokens)
assert get_session_env("HERMES_SESSION_PLATFORM") == "discord"
def test_get_session_env_default_when_nothing_set(monkeypatch):
"""get_session_env returns default when neither contextvar nor env is set."""
monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False)
assert get_session_env("HERMES_SESSION_PLATFORM") == ""
assert get_session_env("HERMES_SESSION_PLATFORM", "fallback") == "fallback"
def test_set_session_env_handles_missing_optional_fields():
"""_set_session_env should handle None chat_name and thread_id gracefully."""
runner = object.__new__(GatewayRunner)
source = SessionSource(
platform=Platform.TELEGRAM,
chat_id="-1001",
chat_name=None,
chat_type="private",
thread_id=None,
)
context = SessionContext(source=source, connected_platforms=[], home_channels={})
tokens = runner._set_session_env(context)
assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram"
assert get_session_env("HERMES_SESSION_CHAT_ID") == "-1001"
assert get_session_env("HERMES_SESSION_CHAT_NAME") == ""
assert get_session_env("HERMES_SESSION_THREAD_ID") == ""
runner._clear_session_env(tokens)
@@ -1,160 +0,0 @@
"""Regression tests for session-scoped model/provider overrides in gateway agents.
These cover the bug where `/model ...` stored a session override, but fresh
agent constructions still resolved model/provider from global config/runtime.
That let helper agents (and cache-miss main agents) route GPT-5.4 to the wrong
provider, e.g. Nous instead of OpenAI Codex.
"""
import asyncio
import sys
import threading
import types
from unittest.mock import AsyncMock, MagicMock
import pytest
import gateway.run as gateway_run
from gateway.config import Platform
from gateway.session import SessionSource
class _CapturingAgent:
"""Fake agent that records init kwargs for assertions."""
last_init = None
def __init__(self, *args, **kwargs):
type(self).last_init = dict(kwargs)
self.tools = []
def run_conversation(self, user_message: str, conversation_history=None, task_id=None):
return {
"final_response": "ok",
"messages": [],
"api_calls": 1,
}
def _make_runner():
runner = object.__new__(gateway_run.GatewayRunner)
runner.adapters = {}
runner.session_store = None
runner.config = None
runner._voice_mode = {}
runner._ephemeral_system_prompt = ""
runner._prefill_messages = []
runner._reasoning_config = None
runner._show_reasoning = False
runner._provider_routing = {}
runner._fallback_model = None
runner._service_tier = None
runner._running_agents = {}
runner._running_agents_ts = {}
runner._background_tasks = set()
runner._session_db = None
runner._session_model_overrides = {}
runner._pending_model_notes = {}
runner._pending_approvals = {}
runner._agent_cache = {}
runner._agent_cache_lock = threading.Lock()
runner._get_or_create_gateway_honcho = lambda session_key: (None, None)
runner.hooks = MagicMock()
runner.hooks.emit = AsyncMock()
runner.hooks.loaded_hooks = []
return runner
def _codex_override():
return {
"model": "gpt-5.4",
"provider": "openai-codex",
"api_key": "***",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_mode": "codex_responses",
}
def _explode_runtime_resolution():
raise AssertionError(
"global runtime resolution should not run when a complete session override exists"
)
def test_run_agent_prefers_session_override_over_global_runtime(monkeypatch):
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None)
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", _explode_runtime_resolution)
fake_run_agent = types.ModuleType("run_agent")
fake_run_agent.AIAgent = _CapturingAgent
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
_CapturingAgent.last_init = None
runner = _make_runner()
source = SessionSource(
platform=Platform.LOCAL,
chat_id="cli",
chat_name="CLI",
chat_type="dm",
user_id="user-1",
)
session_key = "agent:main:local:dm"
runner._session_model_overrides[session_key] = _codex_override()
result = asyncio.run(
runner._run_agent(
message="ping",
context_prompt="",
history=[],
source=source,
session_id="session-1",
session_key=session_key,
)
)
assert result["final_response"] == "ok"
assert _CapturingAgent.last_init is not None
assert _CapturingAgent.last_init["model"] == "gpt-5.4"
assert _CapturingAgent.last_init["provider"] == "openai-codex"
assert _CapturingAgent.last_init["api_mode"] == "codex_responses"
assert _CapturingAgent.last_init["base_url"] == "https://chatgpt.com/backend-api/codex"
assert _CapturingAgent.last_init["api_key"] == "***"
@pytest.mark.asyncio
async def test_background_task_prefers_session_override_over_global_runtime(monkeypatch):
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", _explode_runtime_resolution)
fake_run_agent = types.ModuleType("run_agent")
fake_run_agent.AIAgent = _CapturingAgent
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
_CapturingAgent.last_init = None
runner = _make_runner()
adapter = AsyncMock()
adapter.send = AsyncMock()
adapter.extract_media = MagicMock(return_value=([], "ok"))
adapter.extract_images = MagicMock(return_value=([], "ok"))
runner.adapters[Platform.TELEGRAM] = adapter
source = SessionSource(
platform=Platform.TELEGRAM,
user_id="12345",
chat_id="67890",
user_name="testuser",
)
session_key = runner._session_key_for_source(source)
runner._session_model_overrides[session_key] = _codex_override()
await runner._run_background_task("say hello", source, "bg_test")
assert _CapturingAgent.last_init is not None
assert _CapturingAgent.last_init["model"] == "gpt-5.4"
assert _CapturingAgent.last_init["provider"] == "openai-codex"
assert _CapturingAgent.last_init["api_mode"] == "codex_responses"
assert _CapturingAgent.last_init["base_url"] == "https://chatgpt.com/backend-api/codex"
assert _CapturingAgent.last_init["api_key"] == "***"
-9
View File
@@ -41,15 +41,6 @@ def _make_runner():
runner._pending_approvals = {}
runner._voice_mode = {}
runner._background_tasks = set()
runner._draining = False
runner._restart_requested = False
runner._restart_task_started = False
runner._restart_detached = False
runner._restart_via_service = False
runner._restart_drain_timeout = 0.0
runner._stop_task = None
runner._exit_code = None
runner._update_runtime_status = MagicMock()
runner._is_user_authorized = lambda _source: True
runner.hooks = MagicMock()
runner.hooks.emit = AsyncMock()
@@ -1,75 +0,0 @@
"""Tests for _clear_stale_openai_base_url() cleanup after provider switch (#5161)."""
from __future__ import annotations
from unittest.mock import patch
from hermes_cli.config import load_config, save_config, save_env_value, get_env_value
def _write_provider(provider: str, model: str = "test-model"):
"""Helper: write a provider + model to config.yaml."""
cfg = load_config()
model_cfg = cfg.get("model", {})
if not isinstance(model_cfg, dict):
model_cfg = {}
model_cfg["provider"] = provider
model_cfg["default"] = model
cfg["model"] = model_cfg
save_config(cfg)
class TestClearStaleOpenaiBaseUrl:
"""_clear_stale_openai_base_url() removes OPENAI_BASE_URL when provider is not custom."""
def test_clears_when_provider_is_named(self, monkeypatch):
"""OPENAI_BASE_URL is cleared when config provider is a named provider."""
from hermes_cli.main import _clear_stale_openai_base_url
_write_provider("openrouter")
save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
_clear_stale_openai_base_url()
result = get_env_value("OPENAI_BASE_URL")
assert not result, f"Expected OPENAI_BASE_URL to be cleared, got: {result!r}"
def test_preserves_when_provider_is_custom(self, monkeypatch):
"""OPENAI_BASE_URL is NOT cleared when config provider is 'custom'."""
from hermes_cli.main import _clear_stale_openai_base_url
_write_provider("custom")
save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
_clear_stale_openai_base_url()
result = get_env_value("OPENAI_BASE_URL")
assert result == "http://localhost:11434/v1", \
f"Expected OPENAI_BASE_URL to be preserved, got: {result!r}"
def test_noop_when_no_openai_base_url(self, monkeypatch):
"""No error when OPENAI_BASE_URL is not set."""
from hermes_cli.main import _clear_stale_openai_base_url
_write_provider("openrouter")
# Ensure it's not set
save_env_value("OPENAI_BASE_URL", "")
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
# Should not raise
_clear_stale_openai_base_url()
def test_noop_when_provider_empty(self, monkeypatch):
"""No cleanup when provider is not set in config."""
from hermes_cli.main import _clear_stale_openai_base_url
cfg = load_config()
cfg.pop("model", None)
save_config(cfg)
save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
_clear_stale_openai_base_url()
result = get_env_value("OPENAI_BASE_URL")
assert result == "http://localhost:11434/v1", \
"Should not clear when provider is not configured"
+1 -108
View File
@@ -5,10 +5,6 @@ from pathlib import Path
from types import SimpleNamespace
import hermes_cli.gateway as gateway_cli
from gateway.restart import (
DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT,
GATEWAY_SERVICE_RESTART_EXIT_CODE,
)
class TestSystemdServiceRefresh:
@@ -78,7 +74,7 @@ class TestSystemdServiceRefresh:
assert unit_path.read_text(encoding="utf-8") == "new unit\n"
assert calls[:2] == [
["systemctl", "--user", "daemon-reload"],
["systemctl", "--user", "reload-or-restart", gateway_cli.get_service_name()],
["systemctl", "--user", "restart", gateway_cli.get_service_name()],
]
@@ -88,8 +84,6 @@ class TestGeneratedSystemdUnits:
assert "ExecStart=" in unit
assert "ExecStop=" not in unit
assert "ExecReload=/bin/kill -USR1 $MAINPID" in unit
assert f"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}" in unit
assert "TimeoutStopSec=60" in unit
def test_user_unit_includes_resolved_node_directory_in_path(self, monkeypatch):
@@ -104,8 +98,6 @@ class TestGeneratedSystemdUnits:
assert "ExecStart=" in unit
assert "ExecStop=" not in unit
assert "ExecReload=/bin/kill -USR1 $MAINPID" in unit
assert f"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}" in unit
assert "TimeoutStopSec=60" in unit
assert "WantedBy=multi-user.target" in unit
@@ -165,31 +157,6 @@ class TestGatewayStopCleanup:
class TestLaunchdServiceRecovery:
def test_get_restart_drain_timeout_prefers_env_then_config_then_default(self, monkeypatch):
monkeypatch.delenv("HERMES_RESTART_DRAIN_TIMEOUT", raising=False)
monkeypatch.setattr(gateway_cli, "read_raw_config", lambda: {})
assert (
gateway_cli._get_restart_drain_timeout()
== DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
)
monkeypatch.setattr(
gateway_cli,
"read_raw_config",
lambda: {"agent": {"restart_drain_timeout": 14}},
)
assert gateway_cli._get_restart_drain_timeout() == 14.0
monkeypatch.setenv("HERMES_RESTART_DRAIN_TIMEOUT", "9")
assert gateway_cli._get_restart_drain_timeout() == 9.0
monkeypatch.setenv("HERMES_RESTART_DRAIN_TIMEOUT", "invalid")
assert (
gateway_cli._get_restart_drain_timeout()
== DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
)
def test_launchd_install_repairs_outdated_plist_without_force(self, tmp_path, monkeypatch):
plist_path = tmp_path / "ai.hermes.gateway.plist"
plist_path.write_text("<plist>old content</plist>", encoding="utf-8")
@@ -267,55 +234,6 @@ class TestLaunchdServiceRecovery:
["launchctl", "kickstart", target],
]
def test_launchd_restart_drains_running_gateway_before_kickstart(self, monkeypatch):
calls = []
target = f"{gateway_cli._launchd_domain()}/{gateway_cli.get_launchd_label()}"
monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 12.0)
monkeypatch.setattr(gateway_cli, "_request_gateway_self_restart", lambda pid: False)
monkeypatch.setattr(gateway_cli, "_wait_for_gateway_exit", lambda timeout, force_after=None: True)
monkeypatch.setattr(gateway_cli, "terminate_pid", lambda pid, force=False: calls.append(("term", pid, force)))
monkeypatch.setattr(
"gateway.status.get_running_pid",
lambda: 321,
)
def fake_run(cmd, check=False, **kwargs):
calls.append(cmd)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
gateway_cli.launchd_restart()
assert calls == [
("term", 321, False),
["launchctl", "kickstart", "-k", target],
]
def test_launchd_restart_self_requests_graceful_restart_without_kickstart(self, monkeypatch, capsys):
calls = []
monkeypatch.setattr(
"gateway.status.get_running_pid",
lambda: 321,
)
monkeypatch.setattr(
gateway_cli,
"_request_gateway_self_restart",
lambda pid: calls.append(("self", pid)) or True,
)
monkeypatch.setattr(
gateway_cli.subprocess,
"run",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("launchctl should not run")),
)
gateway_cli.launchd_restart()
assert calls == [("self", 321)]
assert "restart requested" in capsys.readouterr().out.lower()
def test_launchd_stop_uses_bootout_not_kill(self, monkeypatch):
"""launchd_stop must bootout the service so KeepAlive doesn't respawn it."""
label = gateway_cli.get_launchd_label()
@@ -419,31 +337,6 @@ class TestGatewayServiceDetection:
class TestGatewaySystemServiceRouting:
def test_systemd_restart_self_requests_graceful_restart_without_reload_or_restart(self, monkeypatch, capsys):
calls = []
monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False)
monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: calls.append(("refresh", system)))
monkeypatch.setattr(
"gateway.status.get_running_pid",
lambda: 654,
)
monkeypatch.setattr(
gateway_cli,
"_request_gateway_self_restart",
lambda pid: calls.append(("self", pid)) or True,
)
monkeypatch.setattr(
gateway_cli.subprocess,
"run",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("systemctl should not run")),
)
gateway_cli.systemd_restart()
assert calls == [("refresh", False), ("self", 654)]
assert "restart requested" in capsys.readouterr().out.lower()
def test_gateway_install_passes_system_flags(self, monkeypatch):
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
-279
View File
@@ -1,279 +0,0 @@
"""Tests for WSL detection and WSL-aware gateway behavior."""
import io
import subprocess
import sys
from types import SimpleNamespace
from unittest.mock import patch, MagicMock, mock_open
import pytest
import hermes_cli.gateway as gateway
import hermes_constants
# =============================================================================
# is_wsl() in hermes_constants
# =============================================================================
class TestIsWsl:
"""Test the shared is_wsl() utility."""
def setup_method(self):
# Reset cached value between tests
hermes_constants._wsl_detected = None
def test_detects_wsl2(self):
fake_content = (
"Linux version 5.15.146.1-microsoft-standard-WSL2 "
"(gcc (GCC) 11.2.0) #1 SMP Thu Jan 11 04:09:03 UTC 2024\n"
)
with patch("builtins.open", mock_open(read_data=fake_content)):
assert hermes_constants.is_wsl() is True
def test_detects_wsl1(self):
fake_content = (
"Linux version 4.4.0-19041-Microsoft "
"(Microsoft@Microsoft.com) (gcc version 5.4.0) #1\n"
)
with patch("builtins.open", mock_open(read_data=fake_content)):
assert hermes_constants.is_wsl() is True
def test_native_linux(self):
fake_content = (
"Linux version 6.5.0-44-generic (buildd@lcy02-amd64-015) "
"(x86_64-linux-gnu-gcc-12 (Ubuntu 12.3.0-1ubuntu1~22.04) 12.3.0) #44\n"
)
with patch("builtins.open", mock_open(read_data=fake_content)):
assert hermes_constants.is_wsl() is False
def test_no_proc_version(self):
with patch("builtins.open", side_effect=FileNotFoundError):
assert hermes_constants.is_wsl() is False
def test_result_is_cached(self):
"""After first detection, subsequent calls return the cached value."""
hermes_constants._wsl_detected = True
# Even with open raising, cached value is returned
with patch("builtins.open", side_effect=FileNotFoundError):
assert hermes_constants.is_wsl() is True
# =============================================================================
# _wsl_systemd_operational() in gateway
# =============================================================================
class TestWslSystemdOperational:
"""Test the WSL systemd check."""
def test_running(self, monkeypatch):
monkeypatch.setattr(
gateway.subprocess, "run",
lambda *a, **kw: SimpleNamespace(
returncode=0, stdout="running\n", stderr=""
),
)
assert gateway._wsl_systemd_operational() is True
def test_degraded(self, monkeypatch):
monkeypatch.setattr(
gateway.subprocess, "run",
lambda *a, **kw: SimpleNamespace(
returncode=1, stdout="degraded\n", stderr=""
),
)
assert gateway._wsl_systemd_operational() is True
def test_starting(self, monkeypatch):
monkeypatch.setattr(
gateway.subprocess, "run",
lambda *a, **kw: SimpleNamespace(
returncode=1, stdout="starting\n", stderr=""
),
)
assert gateway._wsl_systemd_operational() is True
def test_offline_no_systemd(self, monkeypatch):
monkeypatch.setattr(
gateway.subprocess, "run",
lambda *a, **kw: SimpleNamespace(
returncode=1, stdout="offline\n", stderr=""
),
)
assert gateway._wsl_systemd_operational() is False
def test_systemctl_not_found(self, monkeypatch):
monkeypatch.setattr(
gateway.subprocess, "run",
MagicMock(side_effect=FileNotFoundError),
)
assert gateway._wsl_systemd_operational() is False
def test_timeout(self, monkeypatch):
monkeypatch.setattr(
gateway.subprocess, "run",
MagicMock(side_effect=subprocess.TimeoutExpired("systemctl", 5)),
)
assert gateway._wsl_systemd_operational() is False
# =============================================================================
# supports_systemd_services() WSL integration
# =============================================================================
class TestSupportsSystemdServicesWSL:
"""Test that supports_systemd_services() handles WSL correctly."""
def test_wsl_with_systemd(self, monkeypatch):
"""WSL + working systemd → True."""
monkeypatch.setattr(gateway, "is_linux", lambda: True)
monkeypatch.setattr(gateway, "is_termux", lambda: False)
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
monkeypatch.setattr(gateway, "_wsl_systemd_operational", lambda: True)
assert gateway.supports_systemd_services() is True
def test_wsl_without_systemd(self, monkeypatch):
"""WSL + no systemd → False."""
monkeypatch.setattr(gateway, "is_linux", lambda: True)
monkeypatch.setattr(gateway, "is_termux", lambda: False)
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
monkeypatch.setattr(gateway, "_wsl_systemd_operational", lambda: False)
assert gateway.supports_systemd_services() is False
def test_native_linux(self, monkeypatch):
"""Native Linux (not WSL) → True without checking systemd."""
monkeypatch.setattr(gateway, "is_linux", lambda: True)
monkeypatch.setattr(gateway, "is_termux", lambda: False)
monkeypatch.setattr(gateway, "is_wsl", lambda: False)
assert gateway.supports_systemd_services() is True
def test_termux_still_excluded(self, monkeypatch):
"""Termux → False regardless of WSL status."""
monkeypatch.setattr(gateway, "is_linux", lambda: True)
monkeypatch.setattr(gateway, "is_termux", lambda: True)
assert gateway.supports_systemd_services() is False
# =============================================================================
# WSL messaging in gateway commands
# =============================================================================
class TestGatewayCommandWSLMessages:
"""Test that WSL users see appropriate guidance."""
def test_install_wsl_no_systemd(self, monkeypatch, capsys):
"""hermes gateway install on WSL without systemd shows guidance."""
monkeypatch.setattr(gateway, "is_linux", lambda: True)
monkeypatch.setattr(gateway, "is_termux", lambda: False)
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
monkeypatch.setattr(gateway, "is_macos", lambda: False)
monkeypatch.setattr(gateway, "is_managed", lambda: False)
args = SimpleNamespace(
gateway_command="install", force=False, system=False,
run_as_user=None,
)
with pytest.raises(SystemExit) as exc_info:
gateway.gateway_command(args)
assert exc_info.value.code == 1
out = capsys.readouterr().out
assert "WSL detected" in out
assert "systemd is not running" in out
assert "hermes gateway run" in out
assert "tmux" in out
def test_start_wsl_no_systemd(self, monkeypatch, capsys):
"""hermes gateway start on WSL without systemd shows guidance."""
monkeypatch.setattr(gateway, "is_linux", lambda: True)
monkeypatch.setattr(gateway, "is_termux", lambda: False)
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
monkeypatch.setattr(gateway, "is_macos", lambda: False)
args = SimpleNamespace(gateway_command="start", system=False)
with pytest.raises(SystemExit) as exc_info:
gateway.gateway_command(args)
assert exc_info.value.code == 1
out = capsys.readouterr().out
assert "WSL detected" in out
assert "hermes gateway run" in out
assert "wsl.conf" in out
def test_install_wsl_with_systemd_warns(self, monkeypatch, capsys):
"""hermes gateway install on WSL with systemd shows warning but proceeds."""
monkeypatch.setattr(gateway, "is_linux", lambda: True)
monkeypatch.setattr(gateway, "is_termux", lambda: False)
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: True)
monkeypatch.setattr(gateway, "is_macos", lambda: False)
monkeypatch.setattr(gateway, "is_managed", lambda: False)
# Mock systemd_install to capture call
install_called = []
monkeypatch.setattr(
gateway, "systemd_install",
lambda **kwargs: install_called.append(kwargs),
)
args = SimpleNamespace(
gateway_command="install", force=False, system=False,
run_as_user=None,
)
gateway.gateway_command(args)
out = capsys.readouterr().out
assert "WSL detected" in out
assert "may not survive WSL restarts" in out
assert len(install_called) == 1 # install still proceeded
def test_status_wsl_running_manual(self, monkeypatch, capsys):
"""hermes gateway status on WSL with manual process shows WSL note."""
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
monkeypatch.setattr(gateway, "is_macos", lambda: False)
monkeypatch.setattr(gateway, "is_termux", lambda: False)
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
monkeypatch.setattr(gateway, "find_gateway_pids", lambda: [12345])
monkeypatch.setattr(gateway, "_runtime_health_lines", lambda: [])
# Stub out the systemd unit path check
monkeypatch.setattr(
gateway, "get_systemd_unit_path",
lambda system=False: SimpleNamespace(exists=lambda: False),
)
monkeypatch.setattr(
gateway, "get_launchd_plist_path",
lambda: SimpleNamespace(exists=lambda: False),
)
args = SimpleNamespace(gateway_command="status", deep=False, system=False)
gateway.gateway_command(args)
out = capsys.readouterr().out
assert "WSL note" in out
assert "tmux or screen" in out
def test_status_wsl_not_running(self, monkeypatch, capsys):
"""hermes gateway status on WSL with no process shows WSL start advice."""
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
monkeypatch.setattr(gateway, "is_macos", lambda: False)
monkeypatch.setattr(gateway, "is_termux", lambda: False)
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
monkeypatch.setattr(gateway, "find_gateway_pids", lambda: [])
monkeypatch.setattr(gateway, "_runtime_health_lines", lambda: [])
monkeypatch.setattr(
gateway, "get_systemd_unit_path",
lambda system=False: SimpleNamespace(exists=lambda: False),
)
monkeypatch.setattr(
gateway, "get_launchd_plist_path",
lambda: SimpleNamespace(exists=lambda: False),
)
args = SimpleNamespace(gateway_command="status", deep=False, system=False)
gateway.gateway_command(args)
out = capsys.readouterr().out
assert "hermes gateway run" in out
assert "tmux" in out
-100
View File
@@ -555,103 +555,3 @@ class TestPromptPluginEnvVars:
# Should not crash, and not save anything
mock_save.assert_not_called()
# ── curses_radiolist ─────────────────────────────────────────────────────
class TestCursesRadiolist:
"""Test the curses_radiolist function (non-TTY fallback path)."""
def test_non_tty_returns_default(self):
from hermes_cli.curses_ui import curses_radiolist
with patch("sys.stdin") as mock_stdin:
mock_stdin.isatty.return_value = False
result = curses_radiolist("Pick one", ["a", "b", "c"], selected=1)
assert result == 1
def test_non_tty_returns_cancel_value(self):
from hermes_cli.curses_ui import curses_radiolist
with patch("sys.stdin") as mock_stdin:
mock_stdin.isatty.return_value = False
result = curses_radiolist("Pick", ["x", "y"], selected=0, cancel_returns=1)
assert result == 1
# ── Provider discovery helpers ───────────────────────────────────────────
class TestProviderDiscovery:
"""Test provider plugin discovery and config helpers."""
def test_get_current_memory_provider_default(self, tmp_path, monkeypatch):
"""Empty config returns empty string."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config_file = tmp_path / "config.yaml"
config_file.write_text("memory:\n provider: ''\n")
from hermes_cli.plugins_cmd import _get_current_memory_provider
result = _get_current_memory_provider()
assert result == ""
def test_get_current_context_engine_default(self, tmp_path, monkeypatch):
"""Default config returns 'compressor'."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config_file = tmp_path / "config.yaml"
config_file.write_text("context:\n engine: compressor\n")
from hermes_cli.plugins_cmd import _get_current_context_engine
result = _get_current_context_engine()
assert result == "compressor"
def test_save_memory_provider(self, tmp_path, monkeypatch):
"""Saving a memory provider persists to config.yaml."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config_file = tmp_path / "config.yaml"
config_file.write_text("memory:\n provider: ''\n")
from hermes_cli.plugins_cmd import _save_memory_provider
_save_memory_provider("honcho")
content = yaml.safe_load(config_file.read_text())
assert content["memory"]["provider"] == "honcho"
def test_save_context_engine(self, tmp_path, monkeypatch):
"""Saving a context engine persists to config.yaml."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config_file = tmp_path / "config.yaml"
config_file.write_text("context:\n engine: compressor\n")
from hermes_cli.plugins_cmd import _save_context_engine
_save_context_engine("lcm")
content = yaml.safe_load(config_file.read_text())
assert content["context"]["engine"] == "lcm"
def test_discover_memory_providers_empty(self):
"""Discovery returns empty list when import fails."""
with patch("plugins.memory.discover_memory_providers",
side_effect=ImportError("no module")):
from hermes_cli.plugins_cmd import _discover_memory_providers
result = _discover_memory_providers()
assert result == []
def test_discover_context_engines_empty(self):
"""Discovery returns empty list when import fails."""
with patch("plugins.context_engine.discover_context_engines",
side_effect=ImportError("no module")):
from hermes_cli.plugins_cmd import _discover_context_engines
result = _discover_context_engines()
assert result == []
# ── Auto-activation fix ──────────────────────────────────────────────────
class TestNoAutoActivation:
"""Verify that plugin engines don't auto-activate when config says 'compressor'."""
def test_compressor_default_ignores_plugin(self):
"""When context.engine is 'compressor', a plugin-registered engine should NOT
be used only explicit config triggers plugin engines."""
# This tests the run_agent.py logic indirectly by checking that the
# code path for default config doesn't call get_plugin_context_engine.
import run_agent as ra_module
source = open(ra_module.__file__).read()
# The old code had: "Even with default config, check if a plugin registered one"
# The fix removes this. Verify it's gone.
assert "Even with default config, check if a plugin registered one" not in source
-51
View File
@@ -4,8 +4,6 @@ import json
import sys
import types
import pytest
from hermes_cli.auth import get_active_provider
from hermes_cli.config import load_config, save_config
from hermes_cli.setup import setup_model_provider
@@ -364,52 +362,3 @@ def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tm
assert config["terminal"]["backend"] == "modal"
assert config["terminal"]["modal_mode"] == "direct"
def test_resolve_hermes_chat_argv_prefers_which(monkeypatch):
from hermes_cli import setup as setup_mod
monkeypatch.setattr(setup_mod.shutil, "which", lambda name: "/usr/local/bin/hermes" if name == "hermes" else None)
assert setup_mod._resolve_hermes_chat_argv() == ["/usr/local/bin/hermes", "chat"]
def test_resolve_hermes_chat_argv_falls_back_to_module(monkeypatch):
from hermes_cli import setup as setup_mod
monkeypatch.setattr(setup_mod.shutil, "which", lambda _name: None)
monkeypatch.setattr(setup_mod.importlib.util, "find_spec", lambda name: object() if name == "hermes_cli" else None)
assert setup_mod._resolve_hermes_chat_argv() == [sys.executable, "-m", "hermes_cli.main", "chat"]
def test_offer_launch_chat_execs_fresh_process(monkeypatch):
from hermes_cli import setup as setup_mod
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True)
monkeypatch.setattr(setup_mod, "_resolve_hermes_chat_argv", lambda: ["/usr/local/bin/hermes", "chat"])
exec_calls = []
def fake_execvp(path, argv):
exec_calls.append((path, argv))
raise SystemExit(0)
monkeypatch.setattr(setup_mod.os, "execvp", fake_execvp)
with pytest.raises(SystemExit):
setup_mod._offer_launch_chat()
assert exec_calls == [("/usr/local/bin/hermes", ["/usr/local/bin/hermes", "chat"])]
def test_offer_launch_chat_manual_fallback_when_unresolvable(monkeypatch, capsys):
from hermes_cli import setup as setup_mod
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True)
monkeypatch.setattr(setup_mod, "_resolve_hermes_chat_argv", lambda: None)
setup_mod._offer_launch_chat()
captured = capsys.readouterr()
assert "Run 'hermes chat' manually" in captured.out
+1 -1
View File
@@ -22,7 +22,7 @@ def _parse_setup_imports():
class TestSetupShutilImport:
def test_shutil_imported_at_module_level(self):
"""shutil must be imported at module level so setup_gateway can use it
for the mautrix auto-install path."""
for the matrix-nio auto-install path (line ~2126)."""
names = _parse_setup_imports()
assert "shutil" in names, (
"shutil is not imported at the top of hermes_cli/setup.py. "
-28
View File
@@ -428,31 +428,3 @@ class TestPlatformToolsetConsistency:
f"Platform {platform!r} in tools_config but missing from "
f"skills_config PLATFORMS"
)
def test_numeric_mcp_server_name_does_not_crash_sorted():
"""YAML parses bare numeric keys (e.g. ``12306:``) as int.
_get_platform_tools must normalise them to str so that sorted()
on the returned set never raises TypeError on mixed int/str.
Regression test for https://github.com/NousResearch/hermes-agent/issues/6901
"""
config = {
"platform_toolsets": {"cli": ["web", 12306]},
"mcp_servers": {
12306: {"url": "https://example.com/mcp"},
"normal-server": {"url": "https://example.com/mcp2"},
},
}
enabled = _get_platform_tools(config, "cli")
# All names must be str — no int leaking through
assert all(isinstance(name, str) for name in enabled), (
f"Non-string toolset names found: {enabled}"
)
assert "12306" in enabled
# sorted() must not raise TypeError
sorted(enabled)
-42
View File
@@ -500,48 +500,6 @@ class TestObservationModeMigration:
assert cfg.ai_observe_others is True
class TestInitOnSessionStart:
"""Tests for the initOnSessionStart config field."""
def test_default_is_false(self):
config = HonchoClientConfig()
assert config.init_on_session_start is False
def test_root_level_true(self, tmp_path):
cfg_file = tmp_path / "config.json"
cfg_file.write_text(json.dumps({
"apiKey": "k",
"initOnSessionStart": True,
}))
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
assert cfg.init_on_session_start is True
def test_host_block_overrides_root(self, tmp_path):
cfg_file = tmp_path / "config.json"
cfg_file.write_text(json.dumps({
"apiKey": "k",
"initOnSessionStart": True,
"hosts": {"hermes": {"initOnSessionStart": False}},
}))
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
assert cfg.init_on_session_start is False
def test_host_block_true_overrides_root_absent(self, tmp_path):
cfg_file = tmp_path / "config.json"
cfg_file.write_text(json.dumps({
"apiKey": "k",
"hosts": {"hermes": {"initOnSessionStart": True}},
}))
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
assert cfg.init_on_session_start is True
def test_absent_everywhere_defaults_false(self, tmp_path):
cfg_file = tmp_path / "config.json"
cfg_file.write_text(json.dumps({"apiKey": "k"}))
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
assert cfg.init_on_session_start is False
class TestResetHonchoClient:
def test_reset_clears_singleton(self):
import plugins.memory.honcho.client as mod
-91
View File
@@ -275,97 +275,6 @@ class TestPeerLookupHelpers:
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Provider init behavior: lazy vs eager in tools mode
# ---------------------------------------------------------------------------
class TestToolsModeInitBehavior:
"""Verify initOnSessionStart controls session init timing in tools mode."""
def _make_provider_with_config(self, recall_mode="tools", init_on_session_start=False,
peer_name=None, user_id=None):
"""Create a HonchoMemoryProvider with mocked config and dependencies."""
from plugins.memory.honcho.client import HonchoClientConfig
cfg = HonchoClientConfig(
api_key="test-key",
enabled=True,
recall_mode=recall_mode,
init_on_session_start=init_on_session_start,
peer_name=peer_name,
)
provider = HonchoMemoryProvider()
# Patch the config loading and session init to avoid real Honcho calls
from unittest.mock import patch, MagicMock
mock_manager = MagicMock()
mock_session = MagicMock()
mock_session.messages = []
mock_manager.get_or_create.return_value = mock_session
init_kwargs = {}
if user_id:
init_kwargs["user_id"] = user_id
with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \
patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \
patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \
patch("hermes_constants.get_hermes_home", return_value=MagicMock()):
provider.initialize(session_id="test-session-001", **init_kwargs)
return provider, cfg
def test_tools_lazy_default(self):
"""tools + initOnSessionStart=false → session NOT initialized after initialize()."""
provider, _ = self._make_provider_with_config(
recall_mode="tools", init_on_session_start=False,
)
assert provider._session_initialized is False
assert provider._manager is None
assert provider._lazy_init_kwargs is not None
def test_tools_eager_init(self):
"""tools + initOnSessionStart=true → session IS initialized after initialize()."""
provider, _ = self._make_provider_with_config(
recall_mode="tools", init_on_session_start=True,
)
assert provider._session_initialized is True
assert provider._manager is not None
def test_tools_eager_prefetch_still_empty(self):
"""tools mode with eager init still returns empty from prefetch() (no auto-injection)."""
provider, _ = self._make_provider_with_config(
recall_mode="tools", init_on_session_start=True,
)
assert provider.prefetch("test query") == ""
def test_tools_lazy_prefetch_empty(self):
"""tools mode with lazy init also returns empty from prefetch()."""
provider, _ = self._make_provider_with_config(
recall_mode="tools", init_on_session_start=False,
)
assert provider.prefetch("test query") == ""
def test_explicit_peer_name_not_overridden_by_user_id(self):
"""Explicit peerName in config must not be replaced by gateway user_id."""
_, cfg = self._make_provider_with_config(
recall_mode="tools", init_on_session_start=True,
peer_name="Kathie", user_id="8439114563",
)
assert cfg.peer_name == "Kathie"
def test_user_id_used_when_no_peer_name(self):
"""Gateway user_id is used as peer_name when no explicit peerName configured."""
_, cfg = self._make_provider_with_config(
recall_mode="tools", init_on_session_start=True,
peer_name=None, user_id="8439114563",
)
assert cfg.peer_name == "8439114563"
class TestChunkMessage:
def test_short_message_single_chunk(self):
result = HonchoMemoryProvider._chunk_message("hello world", 100)
+3 -140
View File
@@ -953,24 +953,14 @@ class TestBuildApiKwargs:
assert kwargs["messages"][0]["content"][0]["text"] == "hi"
assert "cache_control" not in kwargs["messages"][0]["content"][0]
def test_qwen_portal_sends_explicit_max_tokens(self, agent):
"""When the user explicitly sets max_tokens, it should be sent to Qwen Portal."""
def test_qwen_portal_omits_max_tokens(self, agent):
agent.base_url = "https://portal.qwen.ai/v1"
agent._base_url_lower = agent.base_url.lower()
agent.max_tokens = 4096
messages = [{"role": "system", "content": "sys"}, {"role": "user", "content": "hi"}]
kwargs = agent._build_api_kwargs(messages)
assert kwargs["max_tokens"] == 4096
def test_qwen_portal_default_max_tokens(self, agent):
"""When max_tokens is None, Qwen Portal gets a default of 65536
to prevent reasoning models from exhausting their output budget."""
agent.base_url = "https://portal.qwen.ai/v1"
agent._base_url_lower = agent.base_url.lower()
agent.max_tokens = None
messages = [{"role": "system", "content": "sys"}, {"role": "user", "content": "hi"}]
kwargs = agent._build_api_kwargs(messages)
assert kwargs["max_tokens"] == 65536
assert "max_tokens" not in kwargs
assert "max_completion_tokens" not in kwargs
class TestBuildAssistantMessage:
@@ -1833,111 +1823,6 @@ class TestRunConversation:
assert result["final_response"] == "Here is the actual answer."
assert result["api_calls"] == 2 # 1 original + 1 nudge retry
def test_empty_response_triggers_fallback_provider(self, agent):
"""After 3 empty retries, fallback provider is activated and produces content."""
self._setup_agent(agent)
agent.base_url = "http://127.0.0.1:1234/v1"
# Configure a fallback chain
agent._fallback_chain = [{"provider": "openrouter", "model": "anthropic/claude-sonnet-4"}]
agent._fallback_index = 0
agent._fallback_activated = False
empty_resp = _mock_response(content=None, finish_reason="stop")
content_resp = _mock_response(content="Fallback answer.", finish_reason="stop")
# 4 empty (1 orig + 3 retries), then fallback model answers
agent.client.chat.completions.create.side_effect = [
empty_resp, empty_resp, empty_resp, empty_resp, content_resp,
]
fallback_called = {"called": False}
def _mock_fallback():
fallback_called["called"] = True
# Simulate what _try_activate_fallback does: just advance the
# index and set the flag (the client is already mocked).
agent._fallback_index = 1
agent._fallback_activated = True
agent.model = "anthropic/claude-sonnet-4"
agent.provider = "openrouter"
return True
with (
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
patch.object(agent, "_try_activate_fallback", side_effect=_mock_fallback),
):
result = agent.run_conversation("answer me")
assert fallback_called["called"], "Fallback should have been triggered"
assert result["completed"] is True
assert result["final_response"] == "Fallback answer."
def test_empty_response_fallback_also_empty_returns_empty(self, agent):
"""If fallback also returns empty, final response is (empty)."""
self._setup_agent(agent)
agent.base_url = "http://127.0.0.1:1234/v1"
agent._fallback_chain = [{"provider": "openrouter", "model": "anthropic/claude-sonnet-4"}]
agent._fallback_index = 0
agent._fallback_activated = False
empty_resp = _mock_response(content=None, finish_reason="stop")
# 4 empty from primary (1 + 3 retries), fallback activated,
# then 4 more empty from fallback (1 + 3 retries), no more fallbacks
agent.client.chat.completions.create.side_effect = [
empty_resp, empty_resp, empty_resp, empty_resp, # primary exhausted
empty_resp, empty_resp, empty_resp, empty_resp, # fallback exhausted
]
def _mock_fallback():
if agent._fallback_index >= len(agent._fallback_chain):
return False
agent._fallback_index += 1
agent._fallback_activated = True
agent.model = "anthropic/claude-sonnet-4"
agent.provider = "openrouter"
return True
with (
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
patch.object(agent, "_try_activate_fallback", side_effect=_mock_fallback),
):
result = agent.run_conversation("answer me")
assert result["completed"] is True
assert result["final_response"] == "(empty)"
def test_empty_response_emits_status_for_gateway(self, agent):
"""_emit_status is called during empty retries so gateway users see feedback."""
self._setup_agent(agent)
agent.base_url = "http://127.0.0.1:1234/v1"
empty_resp = _mock_response(content=None, finish_reason="stop")
# 4 empty: 1 original + 3 retries, all empty, no fallback
agent.client.chat.completions.create.side_effect = [
empty_resp, empty_resp, empty_resp, empty_resp,
]
status_messages = []
def _capture_status(msg):
status_messages.append(msg)
with (
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
patch.object(agent, "_emit_status", side_effect=_capture_status),
):
result = agent.run_conversation("answer me")
assert result["final_response"] == "(empty)"
# Should have emitted retry statuses (3 retries) + final failure
retry_msgs = [m for m in status_messages if "retrying" in m.lower()]
assert len(retry_msgs) == 3, f"Expected 3 retry status messages, got {len(retry_msgs)}: {status_messages}"
failure_msgs = [m for m in status_messages if "no content" in m.lower() or "no fallback" in m.lower()]
assert len(failure_msgs) >= 1, f"Expected at least 1 failure status, got: {status_messages}"
def test_nous_401_refreshes_after_remint_and_retries(self, agent):
self._setup_agent(agent)
agent.provider = "nous"
@@ -2240,28 +2125,6 @@ class TestRetryExhaustion:
assert "error" in result
assert "rate limited" in result["error"]
def test_build_api_kwargs_error_no_unbound_local(self, agent):
"""When _build_api_kwargs raises, except handler must not crash with UnboundLocalError.
Regression: _dump_api_request_debug(api_kwargs, ...) in the except block
referenced api_kwargs before it was assigned when _build_api_kwargs threw.
"""
self._setup_agent(agent)
with (
patch.object(agent, "_build_api_kwargs", side_effect=ValueError("bad messages")),
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
patch("run_agent.time", self._make_fast_time_mock()),
):
result = agent.run_conversation("hello")
# Must surface the real error, not UnboundLocalError
assert result.get("completed") is False
assert result.get("failed") is True
assert "error" in result
assert "UnboundLocalError" not in result.get("error", "")
assert "bad messages" in result["error"]
# ---------------------------------------------------------------------------
# Flush sentinel leak
+5 -12
View File
@@ -11,19 +11,12 @@ def _load_optional_dependencies():
return project["optional-dependencies"]
def test_matrix_extra_linux_only_in_all():
"""mautrix[encryption] depends on python-olm which is upstream-broken on
modern macOS (archived libolm, C++ errors with Clang 21+). The [matrix]
extra is included in [all] but gated to Linux via a platform marker so
that ``hermes update`` doesn't fail on macOS."""
def test_matrix_extra_exists_but_excluded_from_all():
"""matrix-nio[e2e] depends on python-olm which is upstream-broken on modern
macOS (archived libolm, C++ errors with Clang 21+). The [matrix] extra is
kept for opt-in install but deliberately excluded from [all] so one broken
upstream dep doesn't nuke every other extra during ``hermes update``."""
optional_dependencies = _load_optional_dependencies()
assert "matrix" in optional_dependencies
# Must NOT be unconditional — python-olm has no macOS wheels.
assert "hermes-agent[matrix]" not in optional_dependencies["all"]
# Must be present with a Linux platform marker.
linux_gated = [
dep for dep in optional_dependencies["all"]
if "matrix" in dep and "linux" in dep
]
assert linux_gated, "expected hermes-agent[matrix] with sys_platform=='linux' marker in [all]"
-2
View File
@@ -156,8 +156,6 @@ class TestSessionKeyContext:
assert "reset_current_session_key" in called_names
class TestRmFalsePositiveFix:
"""Regression tests: filenames starting with 'r' must NOT trigger recursive delete."""
-176
View File
@@ -1,176 +0,0 @@
"""Unit tests for tools/budget_config.py.
Covers default values, resolve_threshold() priority chain
(pinned > tool_overrides > registry > default), immutability,
and the PINNED_THRESHOLDS escape-hatch for read_file.
"""
import dataclasses
import math
from unittest.mock import patch
import pytest
from tools.budget_config import (
DEFAULT_BUDGET,
DEFAULT_PREVIEW_SIZE_CHARS,
DEFAULT_RESULT_SIZE_CHARS,
DEFAULT_TURN_BUDGET_CHARS,
PINNED_THRESHOLDS,
BudgetConfig,
)
# ---------------------------------------------------------------------------
# Module-level constants
# ---------------------------------------------------------------------------
class TestModuleConstants:
"""Verify documented default values haven't drifted."""
def test_default_result_size(self):
assert DEFAULT_RESULT_SIZE_CHARS == 100_000
def test_default_turn_budget(self):
assert DEFAULT_TURN_BUDGET_CHARS == 200_000
def test_default_preview_size(self):
assert DEFAULT_PREVIEW_SIZE_CHARS == 1_500
class TestPinnedThresholds:
"""PINNED_THRESHOLDS tools whose values must never be overridden."""
def test_read_file_is_inf(self):
assert PINNED_THRESHOLDS["read_file"] == float("inf")
assert math.isinf(PINNED_THRESHOLDS["read_file"])
def test_pinned_is_not_empty(self):
assert len(PINNED_THRESHOLDS) >= 1
# ---------------------------------------------------------------------------
# BudgetConfig defaults
# ---------------------------------------------------------------------------
class TestBudgetConfigDefaults:
"""BudgetConfig() should match the module-level defaults exactly."""
def test_default_result_size(self):
cfg = BudgetConfig()
assert cfg.default_result_size == DEFAULT_RESULT_SIZE_CHARS
def test_default_turn_budget(self):
cfg = BudgetConfig()
assert cfg.turn_budget == DEFAULT_TURN_BUDGET_CHARS
def test_default_preview_size(self):
cfg = BudgetConfig()
assert cfg.preview_size == DEFAULT_PREVIEW_SIZE_CHARS
def test_default_tool_overrides_empty(self):
cfg = BudgetConfig()
assert cfg.tool_overrides == {}
def test_default_budget_singleton_matches(self):
"""DEFAULT_BUDGET should equal a freshly constructed BudgetConfig."""
assert DEFAULT_BUDGET == BudgetConfig()
# ---------------------------------------------------------------------------
# Immutability (frozen=True)
# ---------------------------------------------------------------------------
class TestBudgetConfigFrozen:
"""Frozen dataclass must reject attribute mutation."""
def test_cannot_set_default_result_size(self):
cfg = BudgetConfig()
with pytest.raises(dataclasses.FrozenInstanceError):
cfg.default_result_size = 999
def test_cannot_set_turn_budget(self):
cfg = BudgetConfig()
with pytest.raises(dataclasses.FrozenInstanceError):
cfg.turn_budget = 999
def test_cannot_set_preview_size(self):
cfg = BudgetConfig()
with pytest.raises(dataclasses.FrozenInstanceError):
cfg.preview_size = 999
def test_cannot_set_tool_overrides(self):
cfg = BudgetConfig()
with pytest.raises(dataclasses.FrozenInstanceError):
cfg.tool_overrides = {"foo": 1}
# ---------------------------------------------------------------------------
# Custom construction
# ---------------------------------------------------------------------------
class TestBudgetConfigCustom:
"""BudgetConfig can be created with non-default values."""
def test_custom_values(self):
cfg = BudgetConfig(
default_result_size=50_000,
turn_budget=100_000,
preview_size=500,
tool_overrides={"my_tool": 42},
)
assert cfg.default_result_size == 50_000
assert cfg.turn_budget == 100_000
assert cfg.preview_size == 500
assert cfg.tool_overrides == {"my_tool": 42}
# ---------------------------------------------------------------------------
# resolve_threshold() priority chain
# ---------------------------------------------------------------------------
class TestResolveThreshold:
"""Priority: pinned > tool_overrides > registry > default."""
def test_pinned_wins_over_override(self):
"""Even if tool_overrides contains read_file, pinned value wins."""
cfg = BudgetConfig(tool_overrides={"read_file": 1})
result = cfg.resolve_threshold("read_file")
assert result == float("inf")
def test_tool_override_wins_over_default(self):
"""tool_overrides should be returned before falling back to registry."""
cfg = BudgetConfig(tool_overrides={"my_tool": 42})
result = cfg.resolve_threshold("my_tool")
assert result == 42
@patch("tools.registry.registry")
def test_falls_back_to_registry(self, mock_registry):
"""When not pinned and not in overrides, delegate to registry."""
mock_registry.get_max_result_size.return_value = 77_777
cfg = BudgetConfig()
result = cfg.resolve_threshold("some_tool")
mock_registry.get_max_result_size.assert_called_once_with(
"some_tool", default=DEFAULT_RESULT_SIZE_CHARS
)
assert result == 77_777
@patch("tools.registry.registry")
def test_registry_receives_custom_default(self, mock_registry):
"""Custom default_result_size flows through to registry call."""
mock_registry.get_max_result_size.return_value = 50_000
cfg = BudgetConfig(default_result_size=50_000)
cfg.resolve_threshold("unknown_tool")
mock_registry.get_max_result_size.assert_called_once_with(
"unknown_tool", default=50_000
)
def test_pinned_read_file_returns_inf(self):
"""Canonical case: read_file must always return inf."""
cfg = BudgetConfig()
assert cfg.resolve_threshold("read_file") == float("inf")
+3 -4
View File
@@ -205,9 +205,9 @@ class TestMacosOsascript:
class TestIsWsl:
def setup_method(self):
# _is_wsl is now hermes_constants.is_wsl — reset its cache
import hermes_constants
hermes_constants._wsl_detected = None
# Reset cached value before each test
import hermes_cli.clipboard as cb
cb._wsl_detected = None
def test_wsl2_detected(self):
content = "Linux version 5.15.0 (microsoft-standard-WSL2)"
@@ -229,7 +229,6 @@ class TestIsWsl:
assert _is_wsl() is False
def test_result_is_cached(self):
import hermes_constants
content = "Linux version 5.15.0 (microsoft-standard-WSL2)"
with patch("builtins.open", mock_open(read_data=content)) as m:
assert _is_wsl() is True
-68
View File
@@ -1210,73 +1210,5 @@ class TestDelegateHeartbeat(unittest.TestCase):
f"Heartbeat should include last_activity_desc: {touch_calls}")
class TestDelegationReasoningEffort(unittest.TestCase):
"""Tests for delegation.reasoning_effort config override."""
@patch("tools.delegate_tool._load_config")
@patch("run_agent.AIAgent")
def test_inherits_parent_reasoning_when_no_override(self, MockAgent, mock_cfg):
"""With no delegation.reasoning_effort, child inherits parent's config."""
mock_cfg.return_value = {"max_iterations": 50, "reasoning_effort": ""}
MockAgent.return_value = MagicMock()
parent = _make_mock_parent()
parent.reasoning_config = {"enabled": True, "effort": "xhigh"}
_build_child_agent(
task_index=0, goal="test", context=None, toolsets=None,
model=None, max_iterations=50, parent_agent=parent,
)
call_kwargs = MockAgent.call_args[1]
self.assertEqual(call_kwargs["reasoning_config"], {"enabled": True, "effort": "xhigh"})
@patch("tools.delegate_tool._load_config")
@patch("run_agent.AIAgent")
def test_override_reasoning_effort_from_config(self, MockAgent, mock_cfg):
"""delegation.reasoning_effort overrides the parent's level."""
mock_cfg.return_value = {"max_iterations": 50, "reasoning_effort": "low"}
MockAgent.return_value = MagicMock()
parent = _make_mock_parent()
parent.reasoning_config = {"enabled": True, "effort": "xhigh"}
_build_child_agent(
task_index=0, goal="test", context=None, toolsets=None,
model=None, max_iterations=50, parent_agent=parent,
)
call_kwargs = MockAgent.call_args[1]
self.assertEqual(call_kwargs["reasoning_config"], {"enabled": True, "effort": "low"})
@patch("tools.delegate_tool._load_config")
@patch("run_agent.AIAgent")
def test_override_reasoning_effort_none_disables(self, MockAgent, mock_cfg):
"""delegation.reasoning_effort: 'none' disables thinking for subagents."""
mock_cfg.return_value = {"max_iterations": 50, "reasoning_effort": "none"}
MockAgent.return_value = MagicMock()
parent = _make_mock_parent()
parent.reasoning_config = {"enabled": True, "effort": "high"}
_build_child_agent(
task_index=0, goal="test", context=None, toolsets=None,
model=None, max_iterations=50, parent_agent=parent,
)
call_kwargs = MockAgent.call_args[1]
self.assertEqual(call_kwargs["reasoning_config"], {"enabled": False})
@patch("tools.delegate_tool._load_config")
@patch("run_agent.AIAgent")
def test_invalid_reasoning_effort_falls_back_to_parent(self, MockAgent, mock_cfg):
"""Invalid delegation.reasoning_effort falls back to parent's config."""
mock_cfg.return_value = {"max_iterations": 50, "reasoning_effort": "banana"}
MockAgent.return_value = MagicMock()
parent = _make_mock_parent()
parent.reasoning_config = {"enabled": True, "effort": "medium"}
_build_child_agent(
task_index=0, goal="test", context=None, toolsets=None,
model=None, max_iterations=50, parent_agent=parent,
)
call_kwargs = MockAgent.call_args[1]
self.assertEqual(call_kwargs["reasoning_config"], {"enabled": True, "effort": "medium"})
if __name__ == "__main__":
unittest.main()
-22
View File
@@ -333,25 +333,3 @@ class TestShellFileOpsWriteDenied:
result = file_ops.patch_replace("~/.ssh/authorized_keys", "old", "new")
assert result.error is not None
assert "denied" in result.error.lower()
def test_delete_file_denied_path(self, file_ops):
result = file_ops.delete_file("~/.ssh/authorized_keys")
assert result.error is not None
assert "denied" in result.error.lower()
def test_move_file_src_denied(self, file_ops):
result = file_ops.move_file("~/.ssh/id_rsa", "/tmp/dest.txt")
assert result.error is not None
assert "denied" in result.error.lower()
def test_move_file_dst_denied(self, file_ops):
result = file_ops.move_file("/tmp/src.txt", "~/.aws/credentials")
assert result.error is not None
assert "denied" in result.error.lower()
def test_move_file_failure_path(self, mock_env):
mock_env.execute.return_value = {"output": "No such file or directory", "returncode": 1}
ops = ShellFileOperations(mock_env)
result = ops.move_file("/tmp/nonexistent.txt", "/tmp/dest.txt")
assert result.error is not None
assert "Failed to move" in result.error
@@ -1,148 +0,0 @@
"""Tests for edge cases in tools/file_operations.py.
Covers:
- ``_is_likely_binary()`` content-analysis branch (dead-code removal regression guard)
- ``_check_lint()`` robustness against file paths containing curly braces
"""
import pytest
from unittest.mock import MagicMock, patch
from tools.file_operations import ShellFileOperations
# =========================================================================
# _is_likely_binary edge cases
# =========================================================================
class TestIsLikelyBinary:
"""Verify content-analysis logic after dead-code removal."""
@pytest.fixture()
def ops(self):
return ShellFileOperations.__new__(ShellFileOperations)
def test_binary_extension_returns_true(self, ops):
"""Known binary extensions should short-circuit without content analysis."""
assert ops._is_likely_binary("image.png") is True
assert ops._is_likely_binary("archive.tar.gz", content_sample="hello") is True
def test_text_content_returns_false(self, ops):
"""Normal printable text should not be classified as binary."""
sample = "Hello, world!\nThis is a normal text file.\n"
assert ops._is_likely_binary("unknown.xyz", content_sample=sample) is False
def test_binary_content_returns_true(self, ops):
"""Content with >30% non-printable characters should be classified as binary."""
# 500 NUL bytes + 500 printable = 50% non-printable → binary
# Use .xyz extension (not in BINARY_EXTENSIONS) to ensure content analysis runs
sample = "\x00" * 500 + "a" * 500
assert ops._is_likely_binary("data.xyz", content_sample=sample) is True
def test_no_content_sample_returns_false(self, ops):
"""When no content sample is provided and extension is unknown → not binary."""
assert ops._is_likely_binary("mystery_file") is False
def test_none_content_sample_returns_false(self, ops):
"""Explicit ``None`` content_sample should behave the same as missing."""
assert ops._is_likely_binary("mystery_file", content_sample=None) is False
def test_empty_string_content_sample_returns_false(self, ops):
"""Empty string is falsy, so content analysis should be skipped → not binary."""
assert ops._is_likely_binary("mystery_file", content_sample="") is False
def test_threshold_boundary(self, ops):
"""Exactly 30% non-printable should NOT trigger binary classification (> 0.30, not >=)."""
# 300 NUL bytes + 700 printable = 30.0% → should be False (uses strict >)
sample = "\x00" * 300 + "a" * 700
assert ops._is_likely_binary("data.xyz", content_sample=sample) is False
def test_just_above_threshold(self, ops):
"""301/1000 = 30.1% non-printable → should be binary."""
sample = "\x00" * 301 + "a" * 699
assert ops._is_likely_binary("data.xyz", content_sample=sample) is True
def test_tabs_and_newlines_excluded(self, ops):
"""Tabs, carriage returns, and newlines should not count as non-printable."""
sample = "\t" * 400 + "\n" * 300 + "\r" * 200 + "a" * 100
assert ops._is_likely_binary("file.txt", content_sample=sample) is False
def test_content_sample_longer_than_1000(self, ops):
"""Only the first 1000 characters should be analysed."""
# First 1000 chars: 200 NUL + 800 printable = 20% → not binary
# Remaining 1000 chars: all NUL → ignored by [:1000] slice
sample = "\x00" * 200 + "a" * 800 + "\x00" * 1000
assert ops._is_likely_binary("file.xyz", content_sample=sample) is False
# =========================================================================
# _check_lint edge cases
# =========================================================================
class TestCheckLintBracePaths:
"""Verify _check_lint handles file paths with curly braces safely."""
@pytest.fixture()
def ops(self):
obj = ShellFileOperations.__new__(ShellFileOperations)
obj._command_cache = {}
return obj
def test_normal_path(self, ops):
"""Normal path without braces should work as before."""
with patch.object(ops, "_has_command", return_value=True), \
patch.object(ops, "_exec") as mock_exec:
mock_exec.return_value = MagicMock(exit_code=0, stdout="")
result = ops._check_lint("/tmp/test_file.py")
assert result.success is True
# Verify the command was built correctly
cmd_arg = mock_exec.call_args[0][0]
assert "'/tmp/test_file.py'" in cmd_arg
def test_path_with_curly_braces(self, ops):
"""Path containing ``{`` and ``}`` must not raise KeyError/ValueError."""
with patch.object(ops, "_has_command", return_value=True), \
patch.object(ops, "_exec") as mock_exec:
mock_exec.return_value = MagicMock(exit_code=0, stdout="")
# This would raise KeyError with .format() but works with .replace()
result = ops._check_lint("/tmp/{test}_file.py")
assert result.success is True
cmd_arg = mock_exec.call_args[0][0]
assert "{test}" in cmd_arg
def test_path_with_nested_braces(self, ops):
"""Path with complex brace patterns like ``{{var}}`` should be safe."""
with patch.object(ops, "_has_command", return_value=True), \
patch.object(ops, "_exec") as mock_exec:
mock_exec.return_value = MagicMock(exit_code=0, stdout="")
result = ops._check_lint("/tmp/{{var}}.py")
assert result.success is True
def test_unsupported_extension_skipped(self, ops):
"""Extensions without a linter should return a skipped result."""
result = ops._check_lint("/tmp/file.unknown_ext")
assert result.skipped is True
def test_missing_linter_skipped(self, ops):
"""When the linter binary is not installed, skip gracefully."""
with patch.object(ops, "_has_command", return_value=False):
result = ops._check_lint("/tmp/test.py")
assert result.skipped is True
def test_lint_failure_returns_output(self, ops):
"""When the linter exits non-zero, result should capture output."""
with patch.object(ops, "_has_command", return_value=True), \
patch.object(ops, "_exec") as mock_exec:
mock_exec.return_value = MagicMock(
exit_code=1,
stdout="SyntaxError: invalid syntax",
)
result = ops._check_lint("/tmp/bad.py")
assert result.success is False
assert "SyntaxError" in result.output
-54
View File
@@ -255,57 +255,3 @@ class TestEdgeCases:
mgr.sync(force=True)
upload.assert_not_called() # _file_mtime_key returns None, skipped
class TestBulkUpload:
"""Tests for the optional bulk_upload_fn callback."""
def test_bulk_upload_used_when_provided(self, tmp_files):
"""When bulk_upload_fn is set, it's called instead of per-file upload_fn."""
upload = MagicMock()
bulk_upload = MagicMock()
mgr = FileSyncManager(
get_files_fn=_make_get_files(tmp_files),
upload_fn=upload,
delete_fn=MagicMock(),
bulk_upload_fn=bulk_upload,
)
mgr.sync(force=True)
upload.assert_not_called()
bulk_upload.assert_called_once()
# All 3 files passed as a list of (host, remote) tuples
files_arg = bulk_upload.call_args[0][0]
assert len(files_arg) == 3
def test_fallback_to_upload_fn_when_no_bulk(self, tmp_files):
"""Without bulk_upload_fn, per-file upload_fn is used (backwards compat)."""
upload = MagicMock()
mgr = FileSyncManager(
get_files_fn=_make_get_files(tmp_files),
upload_fn=upload,
delete_fn=MagicMock(),
bulk_upload_fn=None,
)
mgr.sync(force=True)
assert upload.call_count == 3
def test_bulk_upload_rollback_on_failure(self, tmp_files):
"""Bulk upload failure rolls back synced state so next sync retries."""
bulk_upload = MagicMock(side_effect=RuntimeError("upload failed"))
mgr = FileSyncManager(
get_files_fn=_make_get_files(tmp_files),
upload_fn=MagicMock(),
delete_fn=MagicMock(),
bulk_upload_fn=bulk_upload,
)
mgr.sync(force=True) # fails, should rollback
# State rolled back: next sync should retry all files
bulk_upload.side_effect = None
bulk_upload.reset_mock()
mgr.sync(force=True)
bulk_upload.assert_called_once()
assert len(bulk_upload.call_args[0][0]) == 3
+9 -92
View File
@@ -6,31 +6,31 @@ from tools.fuzzy_match import fuzzy_find_and_replace
class TestExactMatch:
def test_single_replacement(self):
content = "hello world"
new, count, _, err = fuzzy_find_and_replace(content, "hello", "hi")
new, count, err = fuzzy_find_and_replace(content, "hello", "hi")
assert err is None
assert count == 1
assert new == "hi world"
def test_no_match(self):
content = "hello world"
new, count, _, err = fuzzy_find_and_replace(content, "xyz", "abc")
new, count, err = fuzzy_find_and_replace(content, "xyz", "abc")
assert count == 0
assert err is not None
assert new == content
def test_empty_old_string(self):
new, count, _, err = fuzzy_find_and_replace("abc", "", "x")
new, count, err = fuzzy_find_and_replace("abc", "", "x")
assert count == 0
assert err is not None
def test_identical_strings(self):
new, count, _, err = fuzzy_find_and_replace("abc", "abc", "abc")
new, count, err = fuzzy_find_and_replace("abc", "abc", "abc")
assert count == 0
assert "identical" in err
def test_multiline_exact(self):
content = "line1\nline2\nline3"
new, count, _, err = fuzzy_find_and_replace(content, "line1\nline2", "replaced")
new, count, err = fuzzy_find_and_replace(content, "line1\nline2", "replaced")
assert err is None
assert count == 1
assert new == "replaced\nline3"
@@ -39,7 +39,7 @@ class TestExactMatch:
class TestWhitespaceDifference:
def test_extra_spaces_match(self):
content = "def foo( x, y ):"
new, count, _, err = fuzzy_find_and_replace(content, "def foo( x, y ):", "def bar(x, y):")
new, count, err = fuzzy_find_and_replace(content, "def foo( x, y ):", "def bar(x, y):")
assert count == 1
assert "bar" in new
@@ -47,7 +47,7 @@ class TestWhitespaceDifference:
class TestIndentDifference:
def test_different_indentation(self):
content = " def foo():\n pass"
new, count, _, err = fuzzy_find_and_replace(content, "def foo():\n pass", "def bar():\n return 1")
new, count, err = fuzzy_find_and_replace(content, "def foo():\n pass", "def bar():\n return 1")
assert count == 1
assert "bar" in new
@@ -55,96 +55,13 @@ class TestIndentDifference:
class TestReplaceAll:
def test_multiple_matches_without_flag_errors(self):
content = "aaa bbb aaa"
new, count, _, err = fuzzy_find_and_replace(content, "aaa", "ccc", replace_all=False)
new, count, err = fuzzy_find_and_replace(content, "aaa", "ccc", replace_all=False)
assert count == 0
assert "Found 2 matches" in err
def test_multiple_matches_with_flag(self):
content = "aaa bbb aaa"
new, count, _, err = fuzzy_find_and_replace(content, "aaa", "ccc", replace_all=True)
new, count, err = fuzzy_find_and_replace(content, "aaa", "ccc", replace_all=True)
assert err is None
assert count == 2
assert new == "ccc bbb ccc"
class TestUnicodeNormalized:
"""Tests for the unicode_normalized strategy (Bug 5)."""
def test_em_dash_matched(self):
"""Em-dash in content should match ASCII '--' in pattern."""
content = "return value\u2014fallback"
new, count, strategy, err = fuzzy_find_and_replace(
content, "return value--fallback", "return value or fallback"
)
assert count == 1, f"Expected match via unicode_normalized, got err={err}"
assert strategy == "unicode_normalized"
assert "return value or fallback" in new
def test_smart_quotes_matched(self):
"""Smart double quotes in content should match straight quotes in pattern."""
content = 'print(\u201chello\u201d)'
new, count, strategy, err = fuzzy_find_and_replace(
content, 'print("hello")', 'print("world")'
)
assert count == 1, f"Expected match via unicode_normalized, got err={err}"
assert "world" in new
def test_no_unicode_skips_strategy(self):
"""When content and pattern have no Unicode variants, strategy is skipped."""
content = "hello world"
# Should match via exact, not unicode_normalized
new, count, strategy, err = fuzzy_find_and_replace(content, "hello", "hi")
assert count == 1
assert strategy == "exact"
class TestBlockAnchorThreshold:
"""Tests for the raised block_anchor threshold (Bug 4)."""
def test_high_similarity_matches(self):
"""A block with >50% middle similarity should match."""
content = "def foo():\n x = 1\n y = 2\n return x + y\n"
pattern = "def foo():\n x = 1\n y = 9\n return x + y"
new, count, strategy, err = fuzzy_find_and_replace(content, pattern, "def foo():\n return 0\n")
# Should match via block_anchor or earlier strategy
assert count == 1
def test_completely_different_middle_does_not_match(self):
"""A block where only first+last lines match but middle is completely different
should NOT match under the raised 0.50 threshold."""
content = (
"class Foo:\n"
" completely = 'unrelated'\n"
" content = 'here'\n"
" nothing = 'in common'\n"
" pass\n"
)
# Pattern has same first/last lines but completely different middle
pattern = (
"class Foo:\n"
" x = 1\n"
" y = 2\n"
" z = 3\n"
" pass"
)
new, count, strategy, err = fuzzy_find_and_replace(content, pattern, "replaced")
# With threshold=0.50, this near-zero-similarity middle should not match
assert count == 0, (
f"Block with unrelated middle should not match under threshold=0.50, "
f"but matched via strategy={strategy}"
)
class TestStrategyNameSurfaced:
"""Tests for the strategy name in the 4-tuple return (Bug 6)."""
def test_exact_strategy_name(self):
new, count, strategy, err = fuzzy_find_and_replace("hello", "hello", "world")
assert strategy == "exact"
assert count == 1
def test_failed_match_returns_none_strategy(self):
new, count, strategy, err = fuzzy_find_and_replace("hello", "xyz", "world")
assert count == 0
assert strategy is None
assert err is not None
@@ -215,7 +215,6 @@ def test_openai_tts_uses_managed_audio_gateway_when_direct_key_absent(monkeypatc
_install_fake_tools_package()
_install_fake_openai_module(captured)
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setenv("TOOL_GATEWAY_DOMAIN", "nousresearch.com")
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
@@ -257,7 +256,6 @@ def test_transcription_uses_model_specific_response_formats(monkeypatch, tmp_pat
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text("stt:\n provider: openai\n")
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setenv("TOOL_GATEWAY_DOMAIN", "nousresearch.com")
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
-39
View File
@@ -104,45 +104,6 @@ class TestStdioPidTracking:
with _lock:
assert fake_pid not in _stdio_pids
def test_kill_orphaned_uses_sigkill_when_available(self, monkeypatch):
"""Unix-like platforms should keep using SIGKILL for orphan cleanup."""
from tools.mcp_tool import _kill_orphaned_mcp_children, _stdio_pids, _lock
fake_pid = 424242
with _lock:
_stdio_pids.clear()
_stdio_pids.add(fake_pid)
fake_sigkill = 9
monkeypatch.setattr(signal, "SIGKILL", fake_sigkill, raising=False)
with patch("tools.mcp_tool.os.kill") as mock_kill:
_kill_orphaned_mcp_children()
mock_kill.assert_called_once_with(fake_pid, fake_sigkill)
with _lock:
assert fake_pid not in _stdio_pids
def test_kill_orphaned_falls_back_without_sigkill(self, monkeypatch):
"""Windows-like signal modules without SIGKILL should fall back to SIGTERM."""
from tools.mcp_tool import _kill_orphaned_mcp_children, _stdio_pids, _lock
fake_pid = 434343
with _lock:
_stdio_pids.clear()
_stdio_pids.add(fake_pid)
monkeypatch.delattr(signal, "SIGKILL", raising=False)
with patch("tools.mcp_tool.os.kill") as mock_kill:
_kill_orphaned_mcp_children()
mock_kill.assert_called_once_with(fake_pid, signal.SIGTERM)
with _lock:
assert fake_pid not in _stdio_pids
# ---------------------------------------------------------------------------
# Fix 3: MCP reload timeout (cli.py)

Some files were not shown because too many files have changed in this diff Show More