Compare commits

..
Author SHA1 Message Date
Fran Fitzpatrick b49a9bff7a fix: enable Matrix Reactions in platform comparison table 2026-04-11 02:58:23 -07:00
jacob-wangandClaude Opus 4.6 3ca0467040 docs: fix ASCII diagram width mismatch in architecture.md
The System Overview ASCII diagram had inconsistent box widths:
- Entry Points box bottom border was 73 chars instead of 71

This caused the docs-site-checks CI to fail on every docs-only PR
due to pre-existing errors in the diagram.

Fix: normalize Entry Points bottom border to 71 characters,
matching the top border width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 02:58:23 -07:00
luyao618andClaude Opus 4.6 627a6d1435 test(tools): add unit tests for budget_config module
Cover default constants, BudgetConfig defaults, frozen immutability,
custom construction, and the resolve_threshold() priority chain
(pinned > tool_overrides > registry > default). 20 tests total.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:58:23 -07:00
luyao618andClaude Opus 4.6 a915d66c72 test(tools): add unit tests for tool_backend_helpers module
Cover all public functions with 50 test cases:
- managed_nous_tools_enabled() feature flag toggling
- normalize_browser_cloud_provider() coercion and defaults
- coerce_modal_mode() / normalize_modal_mode() validation
- has_direct_modal_credentials() env vars and config file detection
- resolve_modal_backend_state() full backend selection matrix
- resolve_openai_audio_api_key() priority chain and edge cases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:58:23 -07:00
40 changed files with 170 additions and 2167 deletions
+44 -216
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():
@@ -1082,12 +1075,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 +1105,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 +1128,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 +1217,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 +1240,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 +1247,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 +1336,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 +1344,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 +1363,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)
@@ -1512,11 +1442,6 @@ def resolve_provider_client(
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 +1474,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 +1490,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 +1569,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 +1785,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 +1814,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 +1826,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 +1836,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 +1855,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 +1864,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 +1889,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 +1902,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 +1997,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 +2035,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 +2068,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 +2111,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 +2139,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 +2229,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 +2263,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 +2273,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 +2292,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
+1 -4
View File
@@ -113,10 +113,7 @@ 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
+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)
+2 -2
View File
@@ -588,7 +588,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 +617,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)
#
+30 -51
View File
@@ -1171,45 +1171,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.
@@ -8909,15 +8870,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 +9004,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 -8
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:
@@ -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)
+1 -10
View File
@@ -352,16 +352,7 @@ class MatrixAdapter(BasePlatformAdapter):
from mautrix.crypto import OlmMachine
from mautrix.crypto.store import MemoryCryptoStore
# account_id and pickle_key are required by mautrix ≥0.21.
# Use the Matrix user ID as account_id for stable identity.
# pickle_key secures in-memory serialisation; derive from
# the same user_id:device_id pair used for the on-disk HMAC.
_acct_id = self._user_id or "hermes"
_pickle_key = f"{_acct_id}:{self._device_id}"
crypto_store = MemoryCryptoStore(
account_id=_acct_id,
pickle_key=_pickle_key,
)
crypto_store = MemoryCryptoStore()
# Restore persisted crypto state from a previous run.
# Uses HMAC to verify integrity before unpickling.
+39 -178
View File
@@ -476,33 +476,6 @@ def _resolve_hermes_bin() -> Optional[list[str]]:
return None
def _format_gateway_process_notification(evt: dict) -> "str | None":
"""Format a watch pattern event from completion_queue into a [SYSTEM:] message."""
evt_type = evt.get("type", "completion")
_sid = evt.get("session_id", "unknown")
_cmd = evt.get("command", "unknown")
if evt_type == "watch_disabled":
return f"[SYSTEM: {evt.get('message', '')}]"
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
return None
class GatewayRunner:
"""
Main gateway controller.
@@ -694,7 +667,6 @@ class GatewayRunner:
def _flush_memories_for_session(
self,
old_session_id: str,
session_key: Optional[str] = None,
):
"""Prompt the agent to save memories/skills before context is lost.
@@ -713,12 +685,15 @@ class GatewayRunner:
return
from run_agent import AIAgent
model, runtime_kwargs = self._resolve_session_agent_runtime(
session_key=session_key,
)
runtime_kwargs = _resolve_runtime_agent_kwargs()
if not runtime_kwargs.get("api_key"):
return
# Resolve model from config — AIAgent's default is OpenRouter-
# formatted ("anthropic/claude-opus-4.6") which fails when the
# active provider is openai-codex.
model = _resolve_gateway_model()
tmp_agent = AIAgent(
**runtime_kwargs,
model=model,
@@ -798,7 +773,6 @@ class GatewayRunner:
async def _async_flush_memories(
self,
old_session_id: str,
session_key: Optional[str] = None,
):
"""Run the sync memory flush in a thread pool so it won't block the event loop."""
loop = asyncio.get_event_loop()
@@ -806,7 +780,6 @@ class GatewayRunner:
None,
self._flush_memories_for_session,
old_session_id,
session_key,
)
@property
@@ -841,46 +814,6 @@ class GatewayRunner:
thread_sessions_per_user=getattr(config, "thread_sessions_per_user", False),
)
def _resolve_session_agent_runtime(
self,
*,
source: Optional[SessionSource] = None,
session_key: Optional[str] = None,
user_config: Optional[dict] = None,
) -> tuple[str, dict]:
"""Resolve model/runtime for a session, honoring session-scoped /model overrides.
If the session override already contains a complete provider bundle
(provider/api_key/base_url/api_mode), prefer it directly instead of
resolving fresh global runtime state first.
"""
resolved_session_key = session_key
if not resolved_session_key and source is not None:
try:
resolved_session_key = self._session_key_for_source(source)
except Exception:
resolved_session_key = None
model = _resolve_gateway_model(user_config)
override = self._session_model_overrides.get(resolved_session_key) if resolved_session_key else None
if override:
override_model = override.get("model", model)
override_runtime = {
"provider": override.get("provider"),
"api_key": override.get("api_key"),
"base_url": override.get("base_url"),
"api_mode": override.get("api_mode"),
}
if override_runtime.get("api_key"):
return override_model, override_runtime
runtime_kwargs = _resolve_runtime_agent_kwargs()
if override and resolved_session_key:
model, runtime_kwargs = self._apply_session_model_override(
resolved_session_key, model, runtime_kwargs
)
return model, runtime_kwargs
def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict:
from agent.smart_model_routing import resolve_turn_route
from hermes_cli.models import resolve_fast_mode_overrides
@@ -1665,7 +1598,7 @@ class GatewayRunner:
for key, entry in _expired_entries:
try:
await self._async_flush_memories(entry.session_id, key)
await self._async_flush_memories(entry.session_id)
# Shut down memory provider and close tool resources
# on the cached agent. Idle agents live in
# _agent_cache (not _running_agents), so look there.
@@ -2934,7 +2867,6 @@ class GatewayRunner:
_hyg_provider = None
_hyg_base_url = None
_hyg_api_key = None
_hyg_data = {}
try:
_hyg_cfg_path = _hermes_home / "config.yaml"
if _hyg_cfg_path.exists():
@@ -2969,17 +2901,15 @@ class GatewayRunner:
_comp_cfg.get("enabled", True)
).lower() in ("true", "1", "yes")
try:
_hyg_model, _hyg_runtime = self._resolve_session_agent_runtime(
source=source,
session_key=session_key,
user_config=_hyg_data if isinstance(_hyg_data, dict) else None,
)
_hyg_provider = _hyg_runtime.get("provider") or _hyg_provider
_hyg_base_url = _hyg_runtime.get("base_url") or _hyg_base_url
_hyg_api_key = _hyg_runtime.get("api_key") or _hyg_api_key
except Exception:
pass
# Resolve provider/base_url from runtime if not in config
if not _hyg_provider or not _hyg_base_url:
try:
_hyg_runtime = _resolve_runtime_agent_kwargs()
_hyg_provider = _hyg_provider or _hyg_runtime.get("provider")
_hyg_base_url = _hyg_base_url or _hyg_runtime.get("base_url")
_hyg_api_key = _hyg_runtime.get("api_key")
except Exception:
pass
# Check custom_providers per-model context_length
# (same fallback as run_agent.py lines 1171-1189).
@@ -3066,11 +2996,7 @@ class GatewayRunner:
try:
from run_agent import AIAgent
_hyg_model, _hyg_runtime = self._resolve_session_agent_runtime(
source=source,
session_key=session_key,
user_config=_hyg_data if isinstance(_hyg_data, dict) else None,
)
_hyg_runtime = _resolve_runtime_agent_kwargs()
if _hyg_runtime.get("api_key"):
_hyg_msgs = [
{"role": m.get("role"), "content": m.get("content")}
@@ -3457,29 +3383,6 @@ class GatewayRunner:
except Exception as e:
logger.error("Process watcher setup error: %s", e)
# Drain watch pattern notifications that arrived during the agent run.
# Watch events and completions share the same queue; completions are
# already handled by the per-process watcher task above, so we only
# inject watch-type events here.
try:
from tools.process_registry import process_registry as _pr
_watch_events = []
while not _pr.completion_queue.empty():
evt = _pr.completion_queue.get_nowait()
evt_type = evt.get("type", "completion")
if evt_type in ("watch_match", "watch_disabled"):
_watch_events.append(evt)
# else: completion events are handled by the watcher task
for evt in _watch_events:
synth_text = _format_gateway_process_notification(evt)
if synth_text:
try:
await self._inject_watch_notification(synth_text, event)
except Exception as e2:
logger.error("Watch notification injection error: %s", e2)
except Exception as e:
logger.debug("Watch queue drain error: %s", e)
# NOTE: Dangerous command approvals are now handled inline by the
# blocking gateway approval mechanism in tools/approval.py. The agent
# thread blocks until the user responds with /approve or /deny, so by
@@ -3582,12 +3485,7 @@ class GatewayRunner:
# post-processing in _process_message_background is skipped
# when already_sent is True, so media files would never be
# delivered without this.
#
# Never skip when the agent failed — the error message is new
# content the user hasn't seen (streaming only sent earlier
# partial output before the failure). Without this guard,
# users see the agent "stop responding without explanation."
if agent_result.get("already_sent") and not agent_result.get("failed"):
if agent_result.get("already_sent"):
if response:
_media_adapter = self.adapters.get(source.platform)
if _media_adapter:
@@ -3749,7 +3647,7 @@ class GatewayRunner:
old_entry = self.session_store._entries.get(session_key)
if old_entry:
_flush_task = asyncio.create_task(
self._async_flush_memories(old_entry.session_id, session_key)
self._async_flush_memories(old_entry.session_id)
)
self._background_tasks.add(_flush_task)
_flush_task.add_done_callback(self._background_tasks.discard)
@@ -5070,11 +4968,7 @@ class GatewayRunner:
_thread_metadata = {"thread_id": source.thread_id} if source.thread_id else None
try:
user_config = _load_gateway_config()
model, runtime_kwargs = self._resolve_session_agent_runtime(
source=source,
user_config=user_config,
)
runtime_kwargs = _resolve_runtime_agent_kwargs()
if not runtime_kwargs.get("api_key"):
await adapter.send(
source.chat_id,
@@ -5083,6 +4977,8 @@ class GatewayRunner:
)
return
user_config = _load_gateway_config()
model = _resolve_gateway_model(user_config)
platform_key = _platform_config_key(source.platform)
from hermes_cli.tools_config import _get_platform_tools
@@ -5242,12 +5138,7 @@ class GatewayRunner:
_thread_meta = {"thread_id": source.thread_id} if source.thread_id else None
try:
user_config = _load_gateway_config()
model, runtime_kwargs = self._resolve_session_agent_runtime(
source=source,
session_key=session_key,
user_config=user_config,
)
runtime_kwargs = _resolve_runtime_agent_kwargs()
if not runtime_kwargs.get("api_key"):
await adapter.send(
source.chat_id,
@@ -5256,6 +5147,8 @@ class GatewayRunner:
)
return
user_config = _load_gateway_config()
model = _resolve_gateway_model(user_config)
platform_key = _platform_config_key(source.platform)
reasoning_config = self._load_reasoning_config()
self._service_tier = self._load_service_tier()
@@ -5592,14 +5485,13 @@ class GatewayRunner:
from agent.manual_compression_feedback import summarize_manual_compression
from agent.model_metadata import estimate_messages_tokens_rough
session_key = self._session_key_for_source(source)
model, runtime_kwargs = self._resolve_session_agent_runtime(
source=source,
session_key=session_key,
)
runtime_kwargs = _resolve_runtime_agent_kwargs()
if not runtime_kwargs.get("api_key"):
return "No provider configured -- cannot compress."
# Resolve model from config (same reason as memory flush above).
model = _resolve_gateway_model()
msgs = [
{"role": m.get("role"), "content": m.get("content")}
for m in history
@@ -5759,7 +5651,7 @@ class GatewayRunner:
# Flush memories for current session before switching
try:
_flush_task = asyncio.create_task(
self._async_flush_memories(current_entry.session_id, session_key)
self._async_flush_memories(current_entry.session_id)
)
self._background_tasks.add(_flush_task)
_flush_task.add_done_callback(self._background_tasks.discard)
@@ -6758,36 +6650,6 @@ class GatewayRunner:
return prefix
return user_text
async def _inject_watch_notification(self, synth_text: str, original_event) -> None:
"""Inject a watch-pattern notification as a synthetic message event.
Uses the source from the original user event to route the notification
back to the correct chat/adapter.
"""
source = getattr(original_event, "source", None)
if not source:
return
platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform)
adapter = None
for p, a in self.adapters.items():
if p.value == platform_name:
adapter = a
break
if not adapter:
return
try:
from gateway.platforms.base import MessageEvent, MessageType
synth_event = MessageEvent(
text=synth_text,
message_type=MessageType.TEXT,
source=source,
internal=True,
)
logger.info("Watch pattern notification — injecting for %s", platform_name)
await adapter.handle_message(synth_event)
except Exception as e:
logger.error("Watch notification injection error: %s", e)
async def _run_process_watcher(self, watcher: dict) -> None:
"""
Periodically check a background process and push updates to the user.
@@ -7360,12 +7222,10 @@ class GatewayRunner:
except Exception:
pass
model = _resolve_gateway_model(user_config)
try:
model, runtime_kwargs = self._resolve_session_agent_runtime(
source=source,
session_key=session_key,
user_config=user_config,
)
runtime_kwargs = _resolve_runtime_agent_kwargs()
except Exception as exc:
return {
"final_response": f"⚠️ Provider authentication failed: {exc}",
@@ -7374,6 +7234,11 @@ class GatewayRunner:
"tools": [],
}
# /model overrides take precedence over config.yaml defaults.
model, runtime_kwargs = self._apply_session_model_override(
session_key, model, runtime_kwargs
)
pr = self._provider_routing
reasoning_config = self._load_reasoning_config()
self._reasoning_config = reasoning_config
@@ -8147,13 +8012,9 @@ class GatewayRunner:
# If streaming already delivered the response, mark it so the
# caller's send() is skipped (avoiding duplicate messages).
# BUT: never suppress delivery when the agent failed — the error
# message is new content the user hasn't seen, and it must reach
# them even if streaming had sent earlier partial output.
_sc = stream_consumer_holder[0]
if _sc and _sc.already_sent and isinstance(response, dict):
if not response.get("failed"):
response["already_sent"] = True
response["already_sent"] = True
return response
+1 -12
View File
@@ -458,7 +458,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 +472,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)
@@ -1020,13 +1016,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",
-36
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.
-2
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",
-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)
+1 -17
View File
@@ -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"] = {}
+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)
+2 -10
View File
@@ -5888,16 +5888,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.
-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:
+8 -339
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,
@@ -1124,8 +1122,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 +1136,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 +1145,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 +1162,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 +1179,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 +1229,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"
-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."""
@@ -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)
+1 -3
View File
@@ -157,9 +157,7 @@ def _make_fake_mautrix():
mautrix_crypto_store = types.ModuleType("mautrix.crypto.store")
class MemoryCryptoStore:
def __init__(self, account_id="", pickle_key=""):
self.account_id = account_id
self.pickle_key = pickle_key
pass
mautrix_crypto_store.MemoryCryptoStore = MemoryCryptoStore
-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()
@@ -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"] == "***"
@@ -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"
-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)
+3 -13
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:
-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."""
@@ -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")
-245
View File
@@ -1,245 +0,0 @@
"""Tests for the Mistral (Voxtral) TTS provider in tools/tts_tool.py."""
import base64
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
for key in ("MISTRAL_API_KEY", "HERMES_SESSION_PLATFORM"):
monkeypatch.delenv(key, raising=False)
@pytest.fixture
def mock_mistral_module():
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_mistral_cls = MagicMock(return_value=mock_client)
fake_module = MagicMock()
fake_module.Mistral = mock_mistral_cls
with patch.dict("sys.modules", {"mistralai": fake_module, "mistralai.client": fake_module}):
yield mock_client
class TestGenerateMistralTts:
def test_missing_api_key_raises_value_error(self, tmp_path, mock_mistral_module):
from tools.tts_tool import _generate_mistral_tts
output_path = str(tmp_path / "test.mp3")
with pytest.raises(ValueError, match="MISTRAL_API_KEY"):
_generate_mistral_tts("Hello", output_path, {})
def test_successful_generation(self, tmp_path, mock_mistral_module, monkeypatch):
from tools.tts_tool import _generate_mistral_tts
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
audio_content = b"fake-audio-bytes"
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
audio_data=base64.b64encode(audio_content).decode()
)
output_path = str(tmp_path / "test.mp3")
result = _generate_mistral_tts("Hello world", output_path, {})
assert result == output_path
assert (tmp_path / "test.mp3").read_bytes() == audio_content
mock_mistral_module.audio.speech.complete.assert_called_once()
mock_mistral_module.__exit__.assert_called_once()
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
assert call_kwargs["input"] == "Hello world"
assert call_kwargs["response_format"] == "mp3"
@pytest.mark.parametrize(
"extension, expected_format",
[(".ogg", "opus"), (".wav", "wav"), (".flac", "flac"), (".mp3", "mp3")],
)
def test_response_format_from_extension(
self, tmp_path, mock_mistral_module, monkeypatch, extension, expected_format
):
from tools.tts_tool import _generate_mistral_tts
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
audio_data=base64.b64encode(b"data").decode()
)
output_path = str(tmp_path / f"test{extension}")
_generate_mistral_tts("Hi", output_path, {})
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
assert call_kwargs["response_format"] == expected_format
def test_voice_id_passed_when_configured(
self, tmp_path, mock_mistral_module, monkeypatch
):
from tools.tts_tool import _generate_mistral_tts
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
audio_data=base64.b64encode(b"data").decode()
)
config = {"mistral": {"voice_id": "my-voice-uuid"}}
_generate_mistral_tts("Hi", str(tmp_path / "test.mp3"), config)
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
assert call_kwargs["voice_id"] == "my-voice-uuid"
def test_default_voice_id_when_absent(
self, tmp_path, mock_mistral_module, monkeypatch
):
from tools.tts_tool import DEFAULT_MISTRAL_TTS_VOICE_ID, _generate_mistral_tts
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
audio_data=base64.b64encode(b"data").decode()
)
_generate_mistral_tts("Hi", str(tmp_path / "test.mp3"), {})
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
assert call_kwargs["voice_id"] == DEFAULT_MISTRAL_TTS_VOICE_ID
def test_default_voice_id_when_empty_string(
self, tmp_path, mock_mistral_module, monkeypatch
):
from tools.tts_tool import DEFAULT_MISTRAL_TTS_VOICE_ID, _generate_mistral_tts
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
audio_data=base64.b64encode(b"data").decode()
)
config = {"mistral": {"voice_id": ""}}
_generate_mistral_tts("Hi", str(tmp_path / "test.mp3"), config)
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
assert call_kwargs["voice_id"] == DEFAULT_MISTRAL_TTS_VOICE_ID
def test_api_error_sanitized(self, tmp_path, mock_mistral_module, monkeypatch):
from tools.tts_tool import _generate_mistral_tts
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
mock_mistral_module.audio.speech.complete.side_effect = RuntimeError(
"secret-key-in-error"
)
with pytest.raises(RuntimeError, match="RuntimeError") as exc_info:
_generate_mistral_tts("Hello", str(tmp_path / "test.mp3"), {})
assert "secret-key-in-error" not in str(exc_info.value)
def test_default_model_used(self, tmp_path, mock_mistral_module, monkeypatch):
from tools.tts_tool import DEFAULT_MISTRAL_TTS_MODEL, _generate_mistral_tts
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
audio_data=base64.b64encode(b"data").decode()
)
_generate_mistral_tts("Hi", str(tmp_path / "test.mp3"), {})
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
assert call_kwargs["model"] == DEFAULT_MISTRAL_TTS_MODEL
def test_model_from_config_overrides_default(
self, tmp_path, mock_mistral_module, monkeypatch
):
from tools.tts_tool import _generate_mistral_tts
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
audio_data=base64.b64encode(b"data").decode()
)
config = {"mistral": {"model": "voxtral-large-tts-9999"}}
_generate_mistral_tts("Hi", str(tmp_path / "test.mp3"), config)
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
assert call_kwargs["model"] == "voxtral-large-tts-9999"
class TestTtsDispatcherMistral:
def test_dispatcher_routes_to_mistral(
self, tmp_path, mock_mistral_module, monkeypatch
):
import json
from tools.tts_tool import text_to_speech_tool
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
audio_data=base64.b64encode(b"audio").decode()
)
output_path = str(tmp_path / "out.mp3")
with patch("tools.tts_tool._load_tts_config", return_value={"provider": "mistral"}):
result = json.loads(text_to_speech_tool("Hello", output_path=output_path))
assert result["success"] is True
assert result["provider"] == "mistral"
mock_mistral_module.audio.speech.complete.assert_called_once()
def test_dispatcher_returns_error_when_sdk_not_installed(self, tmp_path, monkeypatch):
import json
from tools.tts_tool import text_to_speech_tool
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
with patch(
"tools.tts_tool._import_mistral_client", side_effect=ImportError("no module")
), patch("tools.tts_tool._load_tts_config", return_value={"provider": "mistral"}):
result = json.loads(
text_to_speech_tool("Hello", output_path=str(tmp_path / "out.mp3"))
)
assert result["success"] is False
assert "mistralai" in result["error"]
class TestCheckTtsRequirementsMistral:
def test_mistral_sdk_and_key_returns_true(self, mock_mistral_module, monkeypatch):
from tools.tts_tool import check_tts_requirements
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
with patch("tools.tts_tool._import_edge_tts", side_effect=ImportError), \
patch("tools.tts_tool._import_elevenlabs", side_effect=ImportError), \
patch("tools.tts_tool._import_openai_client", side_effect=ImportError), \
patch("tools.tts_tool._check_neutts_available", return_value=False):
assert check_tts_requirements() is True
def test_mistral_key_missing_returns_false(self, mock_mistral_module):
from tools.tts_tool import check_tts_requirements
with patch("tools.tts_tool._import_edge_tts", side_effect=ImportError), \
patch("tools.tts_tool._import_elevenlabs", side_effect=ImportError), \
patch("tools.tts_tool._import_openai_client", side_effect=ImportError), \
patch("tools.tts_tool._check_neutts_available", return_value=False):
assert check_tts_requirements() is False
class TestMistralTtsOpus:
def test_telegram_produces_ogg_and_voice_compatible(
self, tmp_path, mock_mistral_module, monkeypatch
):
import json
from tools.tts_tool import text_to_speech_tool
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
monkeypatch.setenv("HERMES_SESSION_PLATFORM", "telegram")
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
audio_data=base64.b64encode(b"opus-audio").decode()
)
with patch("tools.tts_tool._load_tts_config", return_value={"provider": "mistral"}):
result = json.loads(text_to_speech_tool("Hello"))
assert result["success"] is True
assert result["file_path"].endswith(".ogg")
assert result["voice_compatible"] is True
assert "[[audio_as_voice]]" in result["media_tag"]
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
assert call_kwargs["response_format"] == "opus"
-128
View File
@@ -414,7 +414,6 @@ class TestVisionSafetyGuards:
class FakeResponse:
url = "https://blocked.test/final.png"
headers = {"content-length": "24"}
content = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
def raise_for_status(self):
@@ -534,133 +533,6 @@ class TestTildeExpansion:
assert data["success"] is False
# ---------------------------------------------------------------------------
# file:// URI support
# ---------------------------------------------------------------------------
class TestFileUriSupport:
"""Verify that file:// URIs resolve as local file paths."""
@pytest.mark.asyncio
async def test_file_uri_resolved_as_local_path(self, tmp_path):
"""file:///absolute/path should be treated as a local file."""
img = tmp_path / "photo.png"
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 8)
mock_response = MagicMock()
mock_choice = MagicMock()
mock_choice.message.content = "A test image"
mock_response.choices = [mock_choice]
with (
patch(
"tools.vision_tools._image_to_base64_data_url",
return_value="data:image/png;base64,abc",
),
patch(
"tools.vision_tools.async_call_llm",
new_callable=AsyncMock,
return_value=mock_response,
),
):
result = await vision_analyze_tool(
f"file://{img}", "describe this", "test/model"
)
data = json.loads(result)
assert data["success"] is True
@pytest.mark.asyncio
async def test_file_uri_nonexistent_gives_error(self, tmp_path):
"""file:// pointing to a missing file should fail gracefully."""
result = await vision_analyze_tool(
f"file://{tmp_path}/nonexistent.png", "describe this", "test/model"
)
data = json.loads(result)
assert data["success"] is False
# ---------------------------------------------------------------------------
# Base64 size pre-flight check
# ---------------------------------------------------------------------------
class TestBase64SizeLimit:
"""Verify that oversized images are rejected before hitting the API."""
@pytest.mark.asyncio
async def test_oversized_image_rejected_before_api_call(self, tmp_path):
"""Images exceeding 5 MB base64 should fail with a clear size error."""
img = tmp_path / "huge.png"
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * (4 * 1024 * 1024))
with patch("tools.vision_tools.async_call_llm", new_callable=AsyncMock) as mock_llm:
result = json.loads(await vision_analyze_tool(str(img), "describe this"))
assert result["success"] is False
assert "too large" in result["error"].lower()
mock_llm.assert_not_awaited()
@pytest.mark.asyncio
async def test_small_image_not_rejected(self, tmp_path):
"""Images well under the limit should pass the size check."""
img = tmp_path / "small.png"
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 64)
mock_response = MagicMock()
mock_choice = MagicMock()
mock_choice.message.content = "Small image"
mock_response.choices = [mock_choice]
with (
patch(
"tools.vision_tools.async_call_llm",
new_callable=AsyncMock,
return_value=mock_response,
),
):
result = json.loads(await vision_analyze_tool(str(img), "describe this", "test/model"))
assert result["success"] is True
# ---------------------------------------------------------------------------
# Error classification for 400 responses
# ---------------------------------------------------------------------------
class TestErrorClassification:
"""Verify that API 400 errors produce actionable guidance."""
@pytest.mark.asyncio
async def test_invalid_request_error_gives_image_guidance(self, tmp_path):
"""An invalid_request_error from the API should mention image size/format."""
img = tmp_path / "test.png"
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 8)
api_error = Exception(
"Error code: 400 - {'type': 'error', 'error': "
"{'type': 'invalid_request_error', 'message': 'Invalid request data'}}"
)
with (
patch(
"tools.vision_tools._image_to_base64_data_url",
return_value="data:image/png;base64,abc",
),
patch(
"tools.vision_tools.async_call_llm",
new_callable=AsyncMock,
side_effect=api_error,
),
):
result = json.loads(await vision_analyze_tool(str(img), "describe", "test/model"))
assert result["success"] is False
assert "rejected the image" in result["analysis"].lower()
assert "smaller" in result["analysis"].lower()
class TestVisionRegistration:
def test_vision_analyze_registered(self):
from tools.registry import registry
-304
View File
@@ -1,304 +0,0 @@
"""Tests for watch_patterns background process monitoring feature.
Covers:
- ProcessSession.watch_patterns field
- ProcessRegistry._check_watch_patterns() matching + notification
- Rate limiting (WATCH_MAX_PER_WINDOW) and overload kill switch
- watch_queue population
- Checkpoint persistence of watch_patterns
- Terminal tool schema includes watch_patterns
- Terminal tool handler passes watch_patterns through
"""
import json
import queue
import time
import pytest
from unittest.mock import patch
from tools.process_registry import (
ProcessRegistry,
ProcessSession,
WATCH_MAX_PER_WINDOW,
WATCH_WINDOW_SECONDS,
WATCH_OVERLOAD_KILL_SECONDS,
)
@pytest.fixture()
def registry():
"""Create a fresh ProcessRegistry."""
return ProcessRegistry()
def _make_session(
sid="proc_test_watch",
command="tail -f app.log",
task_id="t1",
watch_patterns=None,
) -> ProcessSession:
s = ProcessSession(
id=sid,
command=command,
task_id=task_id,
started_at=time.time(),
watch_patterns=watch_patterns or [],
)
return s
# =========================================================================
# ProcessSession field defaults
# =========================================================================
class TestProcessSessionField:
def test_default_empty(self):
s = ProcessSession(id="proc_1", command="echo hi")
assert s.watch_patterns == []
assert s._watch_disabled is False
assert s._watch_hits == 0
assert s._watch_suppressed == 0
def test_can_set_patterns(self):
s = _make_session(watch_patterns=["ERROR", "WARN"])
assert s.watch_patterns == ["ERROR", "WARN"]
# =========================================================================
# Pattern matching + queue population
# =========================================================================
class TestCheckWatchPatterns:
def test_no_patterns_no_notification(self, registry):
"""No watch_patterns → no notifications."""
session = _make_session(watch_patterns=[])
registry._check_watch_patterns(session, "ERROR: something broke\n")
assert registry.completion_queue.empty()
def test_no_match_no_notification(self, registry):
"""Output that doesn't match any pattern → no notification."""
session = _make_session(watch_patterns=["ERROR", "FAIL"])
registry._check_watch_patterns(session, "INFO: all good\nDEBUG: fine\n")
assert registry.completion_queue.empty()
def test_basic_match(self, registry):
"""Single matching line triggers a notification."""
session = _make_session(watch_patterns=["ERROR"])
registry._check_watch_patterns(session, "INFO: ok\nERROR: disk full\n")
assert not registry.completion_queue.empty()
evt = registry.completion_queue.get_nowait()
assert evt["type"] == "watch_match"
assert evt["pattern"] == "ERROR"
assert "disk full" in evt["output"]
assert evt["session_id"] == "proc_test_watch"
def test_multiple_patterns(self, registry):
"""First matching pattern is reported."""
session = _make_session(watch_patterns=["WARN", "ERROR"])
registry._check_watch_patterns(session, "ERROR: bad\nWARN: hmm\n")
evt = registry.completion_queue.get_nowait()
# ERROR appears first in the output, and we check patterns in order
# so "WARN" won't match "ERROR: bad" but "ERROR" will
assert evt["pattern"] == "ERROR"
assert "bad" in evt["output"]
def test_disabled_skips(self, registry):
"""Disabled watch produces no notifications."""
session = _make_session(watch_patterns=["ERROR"])
session._watch_disabled = True
registry._check_watch_patterns(session, "ERROR: boom\n")
assert registry.completion_queue.empty()
def test_hit_counter_increments(self, registry):
"""Each delivered notification increments _watch_hits."""
session = _make_session(watch_patterns=["X"])
registry._check_watch_patterns(session, "X\n")
assert session._watch_hits == 1
registry._check_watch_patterns(session, "X\n")
assert session._watch_hits == 2
def test_output_truncation(self, registry):
"""Very long matched output is truncated."""
session = _make_session(watch_patterns=["X"])
# Generate 30 matching lines (more than the 20-line cap)
text = "\n".join(f"X line {i}" for i in range(30)) + "\n"
registry._check_watch_patterns(session, text)
evt = registry.completion_queue.get_nowait()
# Should only have 20 lines max
assert evt["output"].count("\n") <= 20
# =========================================================================
# Rate limiting
# =========================================================================
class TestRateLimiting:
def test_within_window_limit(self, registry):
"""Notifications within the rate limit all get delivered."""
session = _make_session(watch_patterns=["E"])
for i in range(WATCH_MAX_PER_WINDOW):
registry._check_watch_patterns(session, f"E {i}\n")
assert registry.completion_queue.qsize() == WATCH_MAX_PER_WINDOW
def test_exceeds_window_limit(self, registry):
"""Notifications beyond the rate limit are suppressed."""
session = _make_session(watch_patterns=["E"])
for i in range(WATCH_MAX_PER_WINDOW + 5):
registry._check_watch_patterns(session, f"E {i}\n")
# Only WATCH_MAX_PER_WINDOW should be in the queue
assert registry.completion_queue.qsize() == WATCH_MAX_PER_WINDOW
assert session._watch_suppressed == 5
def test_window_resets(self, registry):
"""After the window expires, notifications can flow again."""
session = _make_session(watch_patterns=["E"])
# Fill the window
for i in range(WATCH_MAX_PER_WINDOW):
registry._check_watch_patterns(session, f"E {i}\n")
# One more should be suppressed
registry._check_watch_patterns(session, "E extra\n")
assert session._watch_suppressed == 1
# Fast-forward past window
session._watch_window_start = time.time() - WATCH_WINDOW_SECONDS - 1
registry._check_watch_patterns(session, "E after reset\n")
# Should deliver now (window reset)
assert registry.completion_queue.qsize() == WATCH_MAX_PER_WINDOW + 1
def test_suppressed_count_in_next_delivery(self, registry):
"""Suppressed count is reported in the next successful delivery."""
session = _make_session(watch_patterns=["E"])
for i in range(WATCH_MAX_PER_WINDOW):
registry._check_watch_patterns(session, f"E {i}\n")
# Suppress 3 more
for i in range(3):
registry._check_watch_patterns(session, f"E suppressed {i}\n")
assert session._watch_suppressed == 3
# Fast-forward past window to allow delivery
session._watch_window_start = time.time() - WATCH_WINDOW_SECONDS - 1
registry._check_watch_patterns(session, "E back\n")
# Drain to the last event
last_evt = None
while not registry.completion_queue.empty():
last_evt = registry.completion_queue.get_nowait()
assert last_evt["suppressed"] == 3
assert session._watch_suppressed == 0 # reset after delivery
# =========================================================================
# Overload kill switch
# =========================================================================
class TestOverloadKillSwitch:
def test_sustained_overload_disables(self, registry):
"""Sustained overload beyond threshold permanently disables watching."""
session = _make_session(watch_patterns=["E"])
# Fill the window to trigger rate limit
for i in range(WATCH_MAX_PER_WINDOW):
registry._check_watch_patterns(session, f"E {i}\n")
# Simulate sustained overload: set overload_since to past threshold
session._watch_overload_since = time.time() - WATCH_OVERLOAD_KILL_SECONDS - 1
# Force another suppressed hit
registry._check_watch_patterns(session, "E overload\n")
registry._check_watch_patterns(session, "E overload2\n")
assert session._watch_disabled is True
# Should have a watch_disabled event in the queue
disabled_evts = []
while not registry.completion_queue.empty():
evt = registry.completion_queue.get_nowait()
if evt.get("type") == "watch_disabled":
disabled_evts.append(evt)
assert len(disabled_evts) == 1
assert "too many matches" in disabled_evts[0]["message"]
def test_overload_resets_on_delivery(self, registry):
"""Overload timer resets when a notification gets through."""
session = _make_session(watch_patterns=["E"])
# Start overload tracking
session._watch_overload_since = time.time() - 10
# But window allows delivery → overload should reset
registry._check_watch_patterns(session, "E ok\n")
assert session._watch_overload_since == 0.0
assert session._watch_disabled is False
# =========================================================================
# Checkpoint persistence
# =========================================================================
class TestCheckpointPersistence:
def test_watch_patterns_in_checkpoint(self, registry):
"""watch_patterns is included in checkpoint data."""
session = _make_session(watch_patterns=["ERROR", "FAIL"])
with registry._lock:
registry._running[session.id] = session
with patch("utils.atomic_json_write") as mock_write:
registry._write_checkpoint()
args = mock_write.call_args
entries = args[0][1] # second positional arg
assert len(entries) == 1
assert entries[0]["watch_patterns"] == ["ERROR", "FAIL"]
def test_watch_patterns_recovery(self, registry, tmp_path, monkeypatch):
"""watch_patterns survives checkpoint recovery."""
import tools.process_registry as pr_mod
checkpoint = tmp_path / "processes.json"
checkpoint.write_text(json.dumps([{
"session_id": "proc_recovered",
"command": "tail -f log",
"pid": 99999999, # non-existent
"pid_scope": "host",
"started_at": time.time(),
"task_id": "",
"session_key": "",
"watcher_platform": "",
"watcher_chat_id": "",
"watcher_thread_id": "",
"watcher_interval": 0,
"notify_on_complete": False,
"watch_patterns": ["PANIC", "OOM"],
}]))
monkeypatch.setattr(pr_mod, "CHECKPOINT_PATH", checkpoint)
# PID doesn't exist, so nothing will be recovered
count = registry.recover_from_checkpoint()
# Won't recover since PID is fake, but verify the code path doesn't crash
assert count == 0
# =========================================================================
# Terminal tool schema + handler
# =========================================================================
class TestTerminalToolSchema:
def test_schema_includes_watch_patterns(self):
from tools.terminal_tool import TERMINAL_SCHEMA
props = TERMINAL_SCHEMA["parameters"]["properties"]
assert "watch_patterns" in props
assert props["watch_patterns"]["type"] == "array"
assert props["watch_patterns"]["items"] == {"type": "string"}
def test_handler_passes_watch_patterns(self):
"""_handle_terminal passes watch_patterns to terminal_tool."""
from tools.terminal_tool import _handle_terminal
with patch("tools.terminal_tool.terminal_tool") as mock_tt:
mock_tt.return_value = json.dumps({"output": "ok", "exit_code": 0})
_handle_terminal(
{"command": "echo hi", "watch_patterns": ["ERR"]},
task_id="t1",
)
_, kwargs = mock_tt.call_args
assert kwargs.get("watch_patterns") == ["ERR"]
# =========================================================================
# Code execution tool blocked params
# =========================================================================
class TestCodeExecutionBlocked:
def test_watch_patterns_blocked(self):
from tools.code_execution_tool import _TERMINAL_BLOCKED_PARAMS
assert "watch_patterns" in _TERMINAL_BLOCKED_PARAMS
+1 -1
View File
@@ -301,7 +301,7 @@ def _call(tool_name, args):
# ---------------------------------------------------------------------------
# Terminal parameters that must not be used from ephemeral sandbox scripts
_TERMINAL_BLOCKED_PARAMS = {"background", "check_interval", "pty", "notify_on_complete", "watch_patterns"}
_TERMINAL_BLOCKED_PARAMS = {"background", "check_interval", "pty", "notify_on_complete"}
def _rpc_server_loop(
+12 -115
View File
@@ -58,11 +58,6 @@ MAX_OUTPUT_CHARS = 200_000 # 200KB rolling output buffer
FINISHED_TTL_SECONDS = 1800 # Keep finished processes for 30 minutes
MAX_PROCESSES = 64 # Max concurrent tracked processes (LRU pruning)
# Watch pattern rate limiting
WATCH_MAX_PER_WINDOW = 8 # Max notifications delivered per window
WATCH_WINDOW_SECONDS = 10 # Rolling window length
WATCH_OVERLOAD_KILL_SECONDS = 45 # Sustained overload duration before disabling watch
@dataclass
class ProcessSession:
@@ -88,14 +83,6 @@ class ProcessSession:
watcher_thread_id: str = ""
watcher_interval: int = 0 # 0 = no watcher configured
notify_on_complete: bool = False # Queue agent notification on exit
# Watch patterns — trigger agent notification when output matches any pattern
watch_patterns: List[str] = field(default_factory=list)
_watch_hits: int = field(default=0, repr=False) # total matches delivered
_watch_suppressed: int = field(default=0, repr=False) # matches dropped by rate limit
_watch_overload_since: float = field(default=0.0, repr=False) # when sustained overload began
_watch_disabled: bool = field(default=False, repr=False) # permanently killed by overload
_watch_window_hits: int = field(default=0, repr=False) # hits in current rate window
_watch_window_start: float = field(default=0.0, repr=False)
_lock: threading.Lock = field(default_factory=threading.Lock)
_reader_thread: Optional[threading.Thread] = field(default=None, repr=False)
_pty: Any = field(default=None, repr=False) # ptyprocess handle (when use_pty=True)
@@ -127,10 +114,9 @@ class ProcessRegistry:
# Side-channel for check_interval watchers (gateway reads after agent run)
self.pending_watchers: List[Dict[str, Any]] = []
# Notification queue — unified queue for all background process events.
# Completion notifications (notify_on_complete) and watch pattern matches
# both land here, distinguished by "type" field. CLI process_loop and
# gateway drain this after each agent turn to auto-trigger new turns.
# Completion notifications — processes with notify_on_complete push here
# on exit. CLI process_loop and gateway drain this after each agent turn
# to auto-trigger a new agent turn with the process results.
import queue as _queue_mod
self.completion_queue: _queue_mod.Queue = _queue_mod.Queue()
@@ -142,84 +128,6 @@ class ProcessRegistry:
lines.pop(0)
return "\n".join(lines)
def _check_watch_patterns(self, session: ProcessSession, new_text: str) -> None:
"""Scan new output for watch patterns and queue notifications.
Called from reader threads with new_text being the freshly-read chunk.
Rate-limited: max WATCH_MAX_PER_WINDOW notifications per WATCH_WINDOW_SECONDS.
If sustained overload exceeds WATCH_OVERLOAD_KILL_SECONDS, watching is
disabled permanently for this process.
"""
if not session.watch_patterns or session._watch_disabled:
return
# Scan new text line-by-line for pattern matches
matched_lines = []
matched_pattern = None
for line in new_text.splitlines():
for pat in session.watch_patterns:
if pat in line:
matched_lines.append(line.rstrip())
if matched_pattern is None:
matched_pattern = pat
break # one match per line is enough
if not matched_lines:
return
now = time.time()
with session._lock:
# Reset window if it's expired
if now - session._watch_window_start >= WATCH_WINDOW_SECONDS:
session._watch_window_hits = 0
session._watch_window_start = now
# Check rate limit
if session._watch_window_hits >= WATCH_MAX_PER_WINDOW:
session._watch_suppressed += len(matched_lines)
# Track sustained overload for kill switch
if session._watch_overload_since == 0.0:
session._watch_overload_since = now
elif now - session._watch_overload_since > WATCH_OVERLOAD_KILL_SECONDS:
session._watch_disabled = True
self.completion_queue.put({
"session_id": session.id,
"command": session.command,
"type": "watch_disabled",
"suppressed": session._watch_suppressed,
"message": (
f"Watch patterns disabled for process {session.id} — "
f"too many matches ({session._watch_suppressed} suppressed). "
f"Use process(action='poll') to check output manually."
),
})
return
# Under the rate limit — deliver notification
session._watch_window_hits += 1
session._watch_hits += 1
# Clear overload tracker since we got a delivery through
session._watch_overload_since = 0.0
# Include suppressed count if any events were dropped
suppressed = session._watch_suppressed
session._watch_suppressed = 0
# Trim matched output to a reasonable size
output = "\n".join(matched_lines[:20])
if len(output) > 2000:
output = output[:2000] + "\n...(truncated)"
self.completion_queue.put({
"session_id": session.id,
"command": session.command,
"type": "watch_match",
"pattern": matched_pattern,
"output": output,
"suppressed": suppressed,
})
@staticmethod
def _is_host_pid_alive(pid: Optional[int]) -> bool:
"""Best-effort liveness check for host-visible PIDs."""
@@ -486,18 +394,17 @@ class ProcessRegistry:
session.output_buffer += chunk
if len(session.output_buffer) > session.max_output_chars:
session.output_buffer = session.output_buffer[-session.max_output_chars:]
self._check_watch_patterns(session, chunk)
except Exception as e:
logger.debug("Process stdout reader ended: %s", e)
finally:
# Always reap the child to prevent zombie processes.
try:
session.process.wait(timeout=5)
except Exception as e:
logger.debug("Process wait timed out or failed: %s", e)
session.exited = True
session.exit_code = session.process.returncode
self._move_to_finished(session)
# Process exited
try:
session.process.wait(timeout=5)
except Exception as e:
logger.debug("Process wait timed out or failed: %s", e)
session.exited = True
session.exit_code = session.process.returncode
self._move_to_finished(session)
def _env_poller_loop(
self, session: ProcessSession, env: Any, log_path: str, pid_path: str, exit_path: str
@@ -506,7 +413,6 @@ class ProcessRegistry:
quoted_log_path = shlex.quote(log_path)
quoted_pid_path = shlex.quote(pid_path)
quoted_exit_path = shlex.quote(exit_path)
prev_output_len = 0 # track delta for watch pattern scanning
while not session.exited:
time.sleep(2) # Poll every 2 seconds
try:
@@ -514,15 +420,10 @@ class ProcessRegistry:
result = env.execute(f"cat {quoted_log_path} 2>/dev/null", timeout=10)
new_output = result.get("output", "")
if new_output:
# Compute delta for watch pattern scanning
delta = new_output[prev_output_len:] if len(new_output) > prev_output_len else ""
prev_output_len = len(new_output)
with session._lock:
session.output_buffer = new_output
if len(session.output_buffer) > session.max_output_chars:
session.output_buffer = session.output_buffer[-session.max_output_chars:]
if delta:
self._check_watch_patterns(session, delta)
# Check if process is still running
check = env.execute(
@@ -566,7 +467,6 @@ class ProcessRegistry:
session.output_buffer += text
if len(session.output_buffer) > session.max_output_chars:
session.output_buffer = session.output_buffer[-session.max_output_chars:]
self._check_watch_patterns(session, text)
except EOFError:
break
except Exception:
@@ -602,7 +502,6 @@ class ProcessRegistry:
from tools.ansi_strip import strip_ansi
output_tail = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else ""
self.completion_queue.put({
"type": "completion",
"session_id": session.id,
"command": session.command,
"exit_code": session.exit_code,
@@ -973,7 +872,6 @@ class ProcessRegistry:
"watcher_thread_id": s.watcher_thread_id,
"watcher_interval": s.watcher_interval,
"notify_on_complete": s.notify_on_complete,
"watch_patterns": s.watch_patterns,
})
# Atomic write to avoid corruption on crash
@@ -1034,7 +932,6 @@ class ProcessRegistry:
watcher_thread_id=entry.get("watcher_thread_id", ""),
watcher_interval=entry.get("watcher_interval", 0),
notify_on_complete=entry.get("notify_on_complete", False),
watch_patterns=entry.get("watch_patterns", []),
)
with self._lock:
self._running[session.id] = session
+1 -14
View File
@@ -42,7 +42,7 @@ import atexit
import shutil
import subprocess
from pathlib import Path
from typing import Optional, Dict, Any, List
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
@@ -1140,7 +1140,6 @@ def terminal_tool(
check_interval: Optional[int] = None,
pty: bool = False,
notify_on_complete: bool = False,
watch_patterns: Optional[List[str]] = None,
) -> str:
"""
Execute a command in the configured terminal environment.
@@ -1155,7 +1154,6 @@ def terminal_tool(
check_interval: Seconds between auto-checks for background processes (gateway only, min 30)
pty: If True, use pseudo-terminal for interactive CLI tools (local backend only)
notify_on_complete: If True and background=True, auto-notify the agent when the process exits
watch_patterns: List of strings to watch for in background output; triggers notification on match
Returns:
str: JSON string with output, exit_code, and error fields
@@ -1441,11 +1439,6 @@ def terminal_tool(
"notify_on_complete": True,
})
# Set watch patterns for output monitoring
if watch_patterns and background:
proc_session.watch_patterns = list(watch_patterns)
result_data["watch_patterns"] = proc_session.watch_patterns
# Register check_interval watcher (gateway picks this up after agent run)
if check_interval and background:
effective_interval = max(30, check_interval)
@@ -1769,11 +1762,6 @@ TERMINAL_SCHEMA = {
"type": "boolean",
"description": "When true (and background=true), you'll be automatically notified when the process finishes — no polling needed. Use this for tasks that take a while (tests, builds, deployments) so you can keep working on other things in the meantime.",
"default": False
},
"watch_patterns": {
"type": "array",
"items": {"type": "string"},
"description": "List of strings to watch for in background process output. When any pattern matches a line of output, you'll be notified with the matching text — like notify_on_complete but triggers mid-process on specific output. Use for monitoring logs, watching for errors, or waiting for specific events (e.g. [\"ERROR\", \"FAIL\", \"listening on port\"])."
}
},
"required": ["command"]
@@ -1791,7 +1779,6 @@ def _handle_terminal(args, **kw):
check_interval=args.get("check_interval"),
pty=args.get("pty", False),
notify_on_complete=args.get("notify_on_complete", False),
watch_patterns=args.get("watch_patterns"),
)
+4 -79
View File
@@ -2,12 +2,11 @@
"""
Text-to-Speech Tool Module
Supports six TTS providers:
Supports five TTS providers:
- Edge TTS (default, free, no API key): Microsoft Edge neural voices
- ElevenLabs (premium): High-quality voices, needs ELEVENLABS_API_KEY
- OpenAI TTS: Good quality, needs OPENAI_API_KEY
- MiniMax TTS: High-quality with voice cloning, needs MINIMAX_API_KEY
- Mistral (Voxtral TTS): Multilingual, native Opus, needs MISTRAL_API_KEY
- NeuTTS (local, free, no API key): On-device TTS via neutts_cli, needs neutts installed
Output formats:
@@ -24,7 +23,6 @@ Usage:
"""
import asyncio
import base64
import datetime
import json
import logging
@@ -64,11 +62,6 @@ def _import_openai_client():
from openai import OpenAI as OpenAIClient
return OpenAIClient
def _import_mistral_client():
"""Lazy import Mistral client. Returns the class or raises ImportError."""
from mistralai.client import Mistral
return Mistral
def _import_sounddevice():
"""Lazy import sounddevice. Returns the module or raises ImportError/OSError."""
import sounddevice as sd
@@ -89,8 +82,6 @@ DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
DEFAULT_MINIMAX_MODEL = "speech-2.8-hd"
DEFAULT_MINIMAX_VOICE_ID = "English_Graceful_Lady"
DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1/t2a_v2"
DEFAULT_MISTRAL_TTS_MODEL = "voxtral-mini-tts-2603"
DEFAULT_MISTRAL_TTS_VOICE_ID = "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral
def _get_default_output_dir() -> str:
from hermes_constants import get_hermes_dir
@@ -374,55 +365,6 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any
return output_path
# ===========================================================================
# Provider: Mistral (Voxtral TTS)
# ===========================================================================
def _generate_mistral_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
"""Generate audio using Mistral Voxtral TTS API.
The API returns base64-encoded audio; this function decodes it
and writes the raw bytes to *output_path*.
Supports native Opus output for Telegram voice bubbles.
"""
api_key = os.getenv("MISTRAL_API_KEY", "")
if not api_key:
raise ValueError("MISTRAL_API_KEY not set. Get one at https://console.mistral.ai/")
mi_config = tts_config.get("mistral", {})
model = mi_config.get("model", DEFAULT_MISTRAL_TTS_MODEL)
voice_id = mi_config.get("voice_id") or DEFAULT_MISTRAL_TTS_VOICE_ID
if output_path.endswith(".ogg"):
response_format = "opus"
elif output_path.endswith(".wav"):
response_format = "wav"
elif output_path.endswith(".flac"):
response_format = "flac"
else:
response_format = "mp3"
Mistral = _import_mistral_client()
try:
with Mistral(api_key=api_key) as client:
response = client.audio.speech.complete(
model=model,
input=text,
voice_id=voice_id,
response_format=response_format,
)
audio_bytes = base64.b64decode(response.audio_data)
except ValueError:
raise
except Exception as e:
logger.error("Mistral TTS failed: %s", e, exc_info=True)
raise RuntimeError(f"Mistral TTS failed: {type(e).__name__}") from e
with open(output_path, "wb") as f:
f.write(audio_bytes)
return output_path
# ===========================================================================
# NeuTTS (local, on-device TTS via neutts_cli)
# ===========================================================================
@@ -551,7 +493,7 @@ def text_to_speech_tool(
out_dir.mkdir(parents=True, exist_ok=True)
# Use .ogg for Telegram with providers that support native Opus output,
# otherwise fall back to .mp3 (Edge TTS will attempt ffmpeg conversion later).
if want_opus and provider in ("openai", "elevenlabs", "mistral"):
if want_opus and provider in ("openai", "elevenlabs"):
file_path = out_dir / f"tts_{timestamp}.ogg"
else:
file_path = out_dir / f"tts_{timestamp}.mp3"
@@ -588,18 +530,6 @@ def text_to_speech_tool(
logger.info("Generating speech with MiniMax TTS...")
_generate_minimax_tts(text, file_str, tts_config)
elif provider == "mistral":
try:
_import_mistral_client()
except ImportError:
return json.dumps({
"success": False,
"error": "Mistral provider selected but 'mistralai' package not installed. "
"Run: pip install 'hermes-agent[mistral]'"
}, ensure_ascii=False)
logger.info("Generating speech with Mistral Voxtral TTS...")
_generate_mistral_tts(text, file_str, tts_config)
elif provider == "neutts":
if not _check_neutts_available():
return json.dumps({
@@ -654,7 +584,8 @@ def text_to_speech_tool(
if opus_path:
file_str = opus_path
voice_compatible = True
elif provider in ("elevenlabs", "openai", "mistral"):
elif provider in ("elevenlabs", "openai"):
# These providers can output Opus natively if the path ends in .ogg
voice_compatible = file_str.endswith(".ogg")
file_size = os.path.getsize(file_str)
@@ -722,12 +653,6 @@ def check_tts_requirements() -> bool:
pass
if os.getenv("MINIMAX_API_KEY"):
return True
try:
_import_mistral_client()
if os.getenv("MISTRAL_API_KEY"):
return True
except ImportError:
pass
if _check_neutts_available():
return True
return False
+6 -45
View File
@@ -67,10 +67,6 @@ def _resolve_download_timeout() -> float:
_VISION_DOWNLOAD_TIMEOUT = _resolve_download_timeout()
# Hard cap on downloaded image file size (50 MB). Prevents OOM from
# attacker-hosted multi-gigabyte files or decompression bombs.
_VISION_MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
def _validate_image_url(url: str) -> bool:
"""
@@ -185,25 +181,13 @@ async def _download_image(image_url: str, destination: Path, max_retries: int =
)
response.raise_for_status()
# Reject overly large images early via Content-Length header.
cl = response.headers.get("content-length")
if cl and int(cl) > _VISION_MAX_DOWNLOAD_BYTES:
raise ValueError(
f"Image too large ({int(cl)} bytes, max {_VISION_MAX_DOWNLOAD_BYTES})"
)
final_url = str(response.url)
blocked = check_website_access(final_url)
if blocked:
raise PermissionError(blocked["message"])
# Save the image content (double-check actual size)
body = response.content
if len(body) > _VISION_MAX_DOWNLOAD_BYTES:
raise ValueError(
f"Image too large ({len(body)} bytes, max {_VISION_MAX_DOWNLOAD_BYTES})"
)
destination.write_bytes(body)
# Save the image content
destination.write_bytes(response.content)
return destination
except Exception as e:
@@ -342,11 +326,7 @@ async def vision_analyze_tool(
logger.info("User prompt: %s", user_prompt[:100])
# Determine if this is a local file path or a remote URL
# Strip file:// scheme so file URIs resolve as local paths.
resolved_url = image_url
if resolved_url.startswith("file://"):
resolved_url = resolved_url[len("file://"):]
local_path = Path(os.path.expanduser(resolved_url))
local_path = Path(os.path.expanduser(image_url))
if local_path.is_file():
# Local file path (e.g. from platform image cache) -- skip download
logger.info("Using local image file: %s", image_url)
@@ -382,19 +362,7 @@ async def vision_analyze_tool(
# Calculate size in KB for better readability
data_size_kb = len(image_data_url) / 1024
logger.info("Image converted to base64 (%.1f KB)", data_size_kb)
# Pre-flight size check: most vision APIs cap base64 payloads at 5 MB.
# Reject early with a clear message instead of a cryptic provider 400.
_MAX_BASE64_BYTES = 5 * 1024 * 1024 # 5 MB
# The data URL includes the header (e.g. "data:image/jpeg;base64,") which
# is negligible, but measure the full string to be safe.
if len(image_data_url) > _MAX_BASE64_BYTES:
raise ValueError(
f"Image too large for vision API: base64 payload is "
f"{len(image_data_url) / (1024 * 1024):.1f} MB (limit 5 MB). "
f"Resize or compress the image and try again."
)
debug_call_data["image_size_bytes"] = image_size_bytes
# Use the prompt as provided (model_tools.py now handles full description formatting)
@@ -487,21 +455,14 @@ async def vision_analyze_tool(
f"API provider account and try again. Error: {e}"
)
elif any(hint in err_str for hint in (
"does not support", "not support image",
"content_policy", "multimodal",
"does not support", "not support image", "invalid_request",
"content_policy", "image_url", "multimodal",
"unrecognized request argument", "image input",
)):
analysis = (
f"{model} does not support vision or our request was not "
f"accepted by the server. Error: {e}"
)
elif "invalid_request" in err_str or "image_url" in err_str:
analysis = (
"The vision API rejected the image. This can happen when the "
"image is too large, in an unsupported format, or corrupted. "
"Try a smaller JPEG/PNG (under 3.5 MB) and retry. "
f"Error: {e}"
)
else:
analysis = (
"There was a problem with the request and the image could not "
@@ -145,7 +145,6 @@ ELEVENLABS_API_KEY=***
- `neutts` → free local/on-device TTS
- `elevenlabs` → best quality
- `openai` → good middle ground
- `mistral` → multilingual, native Opus
### If you use `hermes setup`
-1
View File
@@ -864,7 +864,6 @@ You can switch between providers at any time with `hermes model` — no restart
| Image generation | [FAL](https://fal.ai/) | `FAL_KEY` |
| Premium TTS voices | [ElevenLabs](https://elevenlabs.io/) | `ELEVENLABS_API_KEY` |
| OpenAI TTS + voice transcription | [OpenAI](https://platform.openai.com/api-keys) | `VOICE_TOOLS_OPENAI_KEY` |
| Mistral TTS + voice transcription | [Mistral](https://console.mistral.ai/) | `MISTRAL_API_KEY` |
| RL Training | [Tinker](https://tinker-console.thinkingmachines.ai/) + [WandB](https://wandb.ai/) | `TINKER_API_KEY`, `WANDB_API_KEY` |
| Cross-session user modeling | [Honcho](https://honcho.dev/) | `HONCHO_API_KEY` |
| Semantic long-term memory | [Supermemory](https://supermemory.ai) | `SUPERMEMORY_API_KEY` |
+4 -8
View File
@@ -10,7 +10,7 @@ Hermes Agent supports both text-to-speech output and voice message transcription
## Text-to-Speech
Convert text to speech with six providers:
Convert text to speech with five providers:
| Provider | Quality | Cost | API Key |
|----------|---------|------|---------|
@@ -18,7 +18,6 @@ Convert text to speech with six providers:
| **ElevenLabs** | Excellent | Paid | `ELEVENLABS_API_KEY` |
| **OpenAI TTS** | Good | Paid | `VOICE_TOOLS_OPENAI_KEY` |
| **MiniMax TTS** | Excellent | Paid | `MINIMAX_API_KEY` |
| **Mistral (Voxtral TTS)** | Excellent | Paid | `MISTRAL_API_KEY` |
| **NeuTTS** | Good | Free | None needed |
### Platform Delivery
@@ -35,7 +34,7 @@ Convert text to speech with six providers:
```yaml
# In ~/.hermes/config.yaml
tts:
provider: "edge" # "edge" | "elevenlabs" | "openai" | "minimax" | "mistral" | "neutts"
provider: "edge" # "edge" | "elevenlabs" | "openai" | "minimax" | "neutts"
edge:
voice: "en-US-AriaNeural" # 322 voices, 74 languages
elevenlabs:
@@ -51,9 +50,6 @@ tts:
speed: 1 # 0.5 - 2.0
vol: 1 # 0 - 10
pitch: 0 # -12 - 12
mistral:
model: "voxtral-mini-tts-2603"
voice_id: "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral (default)
neutts:
ref_audio: ''
ref_text: ''
@@ -65,7 +61,7 @@ tts:
Telegram voice bubbles require Opus/OGG audio format:
- **OpenAI, ElevenLabs, and Mistral** produce Opus natively — no extra setup
- **OpenAI and ElevenLabs** produce Opus natively — no extra setup
- **Edge TTS** (default) outputs MP3 and needs **ffmpeg** to convert:
- **MiniMax TTS** outputs MP3 and needs **ffmpeg** to convert for Telegram voice bubbles
- **NeuTTS** outputs WAV and also needs **ffmpeg** to convert for Telegram voice bubbles
@@ -84,7 +80,7 @@ sudo dnf install ffmpeg
Without ffmpeg, Edge TTS, MiniMax TTS, and NeuTTS audio are sent as regular audio files (playable, but shown as a rectangular player instead of a voice bubble).
:::tip
If you want voice bubbles without installing ffmpeg, switch to the OpenAI, ElevenLabs, or Mistral provider.
If you want voice bubbles without installing ffmpeg, switch to the OpenAI or ElevenLabs provider.
:::
## Voice Message Transcription (STT)