Background memory review model configuration and fixes
NousResearch/hermes-agent · cluster 8517 · agent
Data as of
Nothing open on GitHub. Fix merged in #12732. Check it covers your case.
The short answer
- Known?
- Yes. 6 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #12732. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and PRs concern problems with model selection for background memory and skill review processes, including removal of smart model routing, bugs around cheap models lacking tool-calling ability, and requests to make the review model configurable.
Open issues0
Open PRs0
Fixes on record
-
#12732 refactor: remove smart_model_routing feature
files:
agent/smart_model_routing.py,cli-config.yaml.example,cli.py,cron/scheduler.py,gateway/run.py,hermes_cli/config.py,hermes_cli/dump.py,hermes_cli/tips.py,hermes_cli/web_server.py,skills/autonomous-ai-agents/hermes-agent/SKILL.md,tests/agent/test_credential_pool_routing.py,tests/agent/test_smart_model_routing.py,tests/cli/test_cli_provider_resolution.py,tests/cli/test_fast_command.py,tests/cron/test_codex_execution_paths.py,tests/gateway/test_discord_channel_prompts.py,tests/gateway/test_fast_command.py,website/docs/integrations/providers.mdShow the diff
-
/dev/null@@ -1,195 +0,0 @@ -"""Helpers for optional cheap-vs-strong model routing.""" - -from __future__ import annotations - -import os -import re -from typing import Any, Dict, Optional - -from utils import is_truthy_value - -_COMPLEX_KEYWORDS = { - "debug", - "debugging", - "implement", - "implementation", - "refactor", - "patch", - "traceback", - "stacktrace", - "exception", - "error", - "analyze", - "analysis", - "investigate", - "architecture", - "design", - "compare", - "benchmark", - "optimize", - "optimise", - "review", - "terminal", - "shell", - "tool", - "tools", - "pytest", - "test", - "tests", - "plan", - "planning", - "delegate", - "subagent", - "cron", - "docker", - "kubernetes", -} - -_URL_RE = re.compile(r"https?://|www\.", re.IGNORECASE) - - -def _coerce_bool(value: Any, default: bool = False) -> bool: - return is_truthy_value(value, default=default) - - -def _coerce_int(value: Any, default: int) -> int: - try: - return int(value) - except (TypeError, ValueError): - return default - - -def choose_cheap_model_route(user_message: str, routing_config: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: - """Return the configured cheap-model route when a message looks simple. - - Conservative by design: if the message has signs of code/tool/debugging/ - long-form work, keep the primary model. - """ - cfg = routing_config or {} - if not _coerce_bool(cfg.get("enabled"), False): - return None - - cheap_model = cfg.get("cheap_model") or {} - if not isinstance(cheap_model, dict): - return None - provider = str(cheap_model.get("provider") or "").strip().lower() - model = str(cheap_model.get("model") or "").strip() - if not provider or not model: - return None - - text = (user_message or "").strip() - if not text: - return None - - max_chars = _coerce_int(cfg.get("max_simple_chars"), 160) - max_words = _coerce_int(cfg.get("max_simple_words"), 28) - - if len(text) > max_chars: - return None - if len(text.split()) > max_words: - return None - if text.count("\n") > 1: - return None - if "```" in text or "`" in text: - return None - if _URL_RE.search(text): - return None - - lowered = text.lower() - words = {token.strip(".,:;!?()[]{}\"'`") for token in lowered.split()} - if words & _COMPLEX_KEYWORDS: - return None - - route = dict(cheap_model) - route["provider"] = provider - route["model"] = model - route["routing_reason"] = "simple_turn" - return route - - -def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any]], primary: Dict[str, Any]) -> Dict[str, Any]: - """Resolve the effective model/runtime for one turn. - - Returns a dict with model/runtime/signature/label fields. - """ - route = choose_cheap_model_route(user_message, routing_config) - if not route: - return { - "model": primary.get("model"), - "runtime": { - "api_key": primary.get("api_key"), - "base_url": primary.get("base_url"), - "provider": primary.get("provider"), - "api_mode": primary.get("api_mode"), - "command": primary.get("command"), - "args": list(primary.get("args") or []), - "credential_pool": primary.get("credential_pool"), - }, - "label": None, - "signature": ( - primary.get("model"), - primary.get("provider"), - primary.get("base_url"), - primary.get("api_mode"), - primary.get("command"), - tuple(primary.get("args") or ()), - ), - } - - from hermes_cli.runtime_provider import resolve_runtime_provider - - explicit_api_key = None - api_key_env = [redacted:secret-assignment]"api_key_env") or "").strip() - if api_key_env: - explicit_api_key = [redacted:secret-assignment] or None - - try: - runtime = resolve_runtime_provider( - requested=route.get("provider"), - explicit_api_key=[redacted:secret-assignment] - explicit_base_url=route.get("base_url"), - ) - except Exception: - return { - "model": primary.get("model"), - "runtime": { - "api_key": primary.get("api_key"), - "base_url": primary.get("base_url"), - "provider": primary.get("provider"), - "api_mode": primary.get("api_mode"), - "command": primary.get("command"), - "args": list(primary.get("args") or []), - "credential_pool": primary.get("credential_pool"), - }, - "label": None, - "signature": ( - primary.get("model"), - primary.get("provider"), - primary.get("base_url"), - primary.get("api_mode"), - primary.get("command"), - tuple(primary.get("args") or ()), - ), - } - - return { - "model": route.get("model"), - "runtime": { - "api_key": runtime.get("api_key"), - "base_url": runtime.get("base_url"), - "provider": runtime.get("provider"), - "api_mode": runtime.get("api_mode"), - "command": runtime.get("command"), - "args": list(runtime.get("args") or []), - "credential_pool": runtime.get("credential_pool"), - }, - "label": f"smart route → {route.get('model')} ({runtime.get('provider')})", - "signature": ( - route.get("model"), - runtime.get("provider"), - runtime.get("base_url"), - runtime.get("api_mode"), - runtime.get("command"), - tuple(runtime.get("args") or ()), - ), - } -
cli-config.yaml.example@@ -114,20 +114,6 @@ model: # # Data policy: "allow" (default) or "deny" to exclude providers that may store data # # data_collection: "deny" -# ============================================================================= -# Smart Model Routing (optional) -# ============================================================================= -# Use a cheaper model for short/simple turns while keeping your main model for -# more complex requests. Disabled by default. -# -# smart_model_routing: -# enabled: true -# max_simple_chars: 160 -# max_simple_words: 28 -# cheap_model: -# provider: openrouter -# model: google/gemini-2.5-flash - # ============================================================================= # Git Worktree Isolation # ============================================================================= -
cli.py@@ -310,12 +310,6 @@ def load_cli_config() -> Dict[str, Any]: "enabled": True, # Auto-compress when approaching context limit "threshold": 0.50, # Compress at 50% of model's context limit }, - "smart_model_routing": { - "enabled": False, - "max_simple_chars": 160, - "max_simple_words": 28, - "cheap_model": {}, - }, "agent": { "max_turns": 90, # Default max tool-calling iterations (shared with subagents) "verbose": False, @@ -1857,8 +1851,9 @@ def __init__( fb = [fb] if fb.get("provider") and fb.get("model") else [] self._fallback_model = fb - # Optional cheap-vs-strong routing for simple turns - self._smart_model_routing = CLI_CONFIG.get("smart_model_routing", {}) or {} + # Signature of the currently-initialised agent's runtime. Used to + # rebuild the agent when provider / model / base_url changes across + # turns (e.g. after /model or credential rotation). self._active_agent_route_signature = None # Agent will be initialized on first use @@ -2883,24 +2878,36 @@ def _ensure_runtime_credentials(self) -> bool: return True def _resolve_turn_agent_config(self, user_message: str) -> dict: - """Resolve model/runtime overrides for a single user turn.""" - from agent.smart_model_routing import resolve_turn_route + """Build the effective model/runtime config for a single user turn. + + Always uses the session's primary model/provider. If the user has + toggled `/fast` on and the current model supports Priority + Processing / Anthropic fast mode, attach `request_overrides` so the + API call is marked accordingly. + """ from hermes_cli.models import resolve_fast_mode_overrides - route = resolve_turn_route( - user_message, - self._smart_model_routing, - { - "model": self.model, - "api_key": self.api_key, - "base_url": self.base_url, - "provider": self.provider, - "api_mode": self.api_mode, - "command": self.acp_command, - "args": list(self.acp_args or []), - "credential_pool": getattr(self, "_credential_pool", None), - }, - ) + runtime = { + "api_key": self.api_key, + "base_url": self.base_url, + "provider": self.provider, + "api_mode": self.api_mode, + "command": self.acp_command, + "args": list(self.acp_args or []), + "credential_pool": getattr(self, "_credential_pool", None), + } + route = { + "model": self.model, + "runtime": runtime, + "signature": ( + self.model, + runtime["provider"], + runtime["base_url"], + runtime["api_mode"], + runtime["command"], + tuple(runtime["args"]), + ), + } service_tier = getattr(self, "service_tier", None) if not service_tier: @@ -2908,13 +2915,13 @@ def _resolve_turn_agent_config(self, user_message: str) -> dict: return route try: - overrides = resolve_fast_mode_overrides(route.get("model")) + overrides = resolve_fast_mode_overrides(route["model"]) except Exception: overrides = None route["request_overrides"] = overrides return route - def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, route_label: str = None, request_overrides: dict | None = None) -> bool: + def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, request_overrides: dict | None = None) -> bool: """ Initialize the agent on first use. When resuming a session, restores conversation history from SQLite. @@ -7911,7 +7918,6 @@ def chat(self, message, images: list = None) -> Optional[str]: if not self._init_agent( model_override=turn_route["model"], runtime_override=turn_route["runtime"], - route_label=turn_route["label"], request_overrides=turn_route.get("request_overrides"), ): return None @@ -10535,7 +10541,6 @@ def _signal_handler_q(signum, frame): if cli._init_agent( model_override=turn_route["model"], runtime_override=turn_route["runtime"], - route_label=turn_route["label"], request_overrides=turn_route.get("request_overrides"), ): cli.agent.quiet_mode = True -
cron/scheduler.py@@ -826,7 +826,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: # Provider routing pr = _cfg.get("provider_routing", {}) - smart_routing = _cfg.get("smart_model_routing", {}) or {} from hermes_cli.runtime_provider import ( resolve_runtime_provider, @@ -843,24 +842,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: message = format_runtime_provider_error(exc) raise RuntimeError(message) from exc - from agent.smart_model_routing import resolve_turn_route - turn_route = resolve_turn_route( - prompt, - smart_routing, - { - "model": model, - "api_key": runtime.get("api_key"), - "base_url": runtime.get("base_url"), - "provider": runtime.get("provider"), - "api_mode": runtime.get("api_mode"), - "command": runtime.get("command"), - "args": list(runtime.get("args") or []), - }, - ) - fallback_model = _cfg.get("fallback_providers") or _cfg.get("fallback_model") or None credential_pool = None - runtime_provider = str(turn_route["runtime"].get("provider") or "").strip().lower() + runtime_provider = str(runtime.get("provider") or "").strip().lower() if runtime_provider: try: from agent.credential_pool import load_pool @@ -877,13 +861,13 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: logger.debug("Job '%s': failed to load credential pool for %s: %s", job_id, runtime_provider, e) agent = AIAgent( - model=turn_route["model"], - api_key=[redacted:secret-assignment]"runtime"].get("api_key"), - base_url=turn_route["runtime"].get("base_url"), - provider=turn_route["runtime"].get("provider"), - api_mode=turn_route["runtime"].get("api_mode"), - acp_command=turn_route["runtime"].get("command"), - acp_args=turn_route["runtime"].get("args"), + model=model, + api_key=[redacted:secret-assignment]"api_key"), + base_url=runtime.get("base_url"), + provider=runtime.get("provider"), + api_mode=runtime.get("api_mode"), + acp_command=runtime.get("command"), + acp_args=runtime.get("args"), max_iterations=max_iterations, reasoning_config=reasoning_config, prefill_messages=prefill_messages, -
gateway/run.py@@ -629,7 +629,6 @@ def __init__(self, config: Optional[GatewayConfig] = None): self._restart_drain_timeout = self._load_restart_drain_timeout() self._provider_routing = self._load_provider_routing() self._fallback_model = self._load_fallback_model() - self._smart_model_routing = self._load_smart_model_routing() # Wire process registry into session store for reset protection from tools.process_registry import process_registry @@ -1082,11 +1081,16 @@ def _resolve_session_agent_runtime( 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 + """Build the effective model/runtime config for a single turn. + + Always uses the session's primary model/provider. If `/fast` is + enabled and the model supports Priority Processing / Anthropic fast + mode, attach `request_overrides` so the API call is marked + accordingly. + """ from hermes_cli.models import resolve_fast_mode_overrides - primary = { - "model": model, + runtime = { "api_key": runtime_kwargs.get("api_key"), "base_url": runtime_kwargs.get("base_url"), "provider": runtime_kwargs.get("provider"), @@ -1095,7 +1099,18 @@ def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwar "args": list(runtime_kwargs.get("args") or []), "credential_pool": runtime_kwargs.get("credential_pool"), } - route = resolve_turn_route(user_message, getattr(self, "_smart_model_routing", {}), primary) + route = { + "model": model, + "runtime": runtime, + "signature": ( + model, + runtime["provider"], + runtime["base_url"], + runtime["api_mode"], + runtime["command"], + tuple(runtime["args"]), + ), + } service_tier = getattr(self, "_service_tier", None) if not service_tier: @@ -1103,7 +1118,7 @@ def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwar return route try: - overrides = resolve_fast_mode_overrides(route.get("model")) + overrides = resolve_fast_mode_overrides(route["model"]) except Exception: overrides = None route["request_overrides"] = overrides @@ -1461,20 +1476,6 @@ def _load_fallback_model() -> list | dict | None: pass return None - @staticmethod - def _load_smart_model_routing() -> dict: - """Load optional smart cheap-vs-strong model routing config.""" - try: - import yaml as _y - cfg_path = _hermes_home / "config.yaml" - if cfg_path.exists(): - with open(cfg_path, encoding="utf-8") as _f: - cfg = _y.safe_load(_f) or {} - return cfg.get("smart_model_routing", {}) or {} - except Exception: - pass - return {} - def _snapshot_running_agents(self) -> Dict[str, Any]: return { session_key: agent -
hermes_cli/config.py@@ -474,13 +474,6 @@ def _ensure_hermes_home_managed(home: Path): }, }, - "smart_model_routing": { - "enabled": False, - "max_simple_chars": 160, - "max_simple_words": 28, - "cheap_model": {}, - }, - # Auxiliary model config — provider:model for each side task. # Format: provider is the provider name, model is the model slug. # "auto" for provider = auto-detect best available provider. @@ -2878,19 +2871,6 @@ def load_config() -> Dict[str, Any]: # fallback_model: # provider: openrouter # model: anthropic/claude-sonnet-4 -# -# ── Smart Model Routing ──────────────────────────────────────────────── -# Optional cheap-vs-strong routing for simple turns. -# Keeps the primary model for complex work, but can route short/simple -# messages to a cheaper model across providers. -# -# smart_model_routing: -# enabled: true -# max_simple_chars: 160 -# max_simple_words: 28 -# cheap_model: -# provider: openrouter -# model: google/gemini-2.5-flash """ @@ -2922,19 +2902,6 @@ def load_config() -> Dict[str, Any]: # fallback_model: # provider: openrouter # model: anthropic/claude-sonnet-4 -# -# ── Smart Model Routing ──────────────────────────────────────────────── -# Optional cheap-vs-strong routing for simple turns. -# Keeps the primary model for complex work, but can route short/simple -# messages to a cheaper model across providers. -# -# smart_model_routing: -# enabled: true -# max_simple_chars: 160 -# max_simple_words: 28 -# cheap_model: -# provider: openrouter -# model: google/gemini-2.5-flash """ -
hermes_cli/dump.py@@ -160,7 +160,6 @@ def _config_overrides(config: dict) -> dict[str, str]: ("display", "streaming"), ("display", "skin"), ("display", "show_reasoning"), - ("smart_model_routing", "enabled"), ("privacy", "redact_pii"), ("tts", "provider"), ] -
hermes_cli/tips.py@@ -323,7 +323,6 @@ "GPT-5 and Codex use 'developer' role instead of 'system' in the message format.", "Per-task auxiliary overrides: auxiliary.vision.provider, auxiliary.compression.model, etc. in config.yaml.", "The auxiliary client treats 'main' as a provider alias — resolves to your actual primary provider + model.", - "Smart routing can auto-route simple queries to a cheaper model — set smart_model_routing.enabled: true.", "hermes claw migrate --dry-run previews OpenClaw migration without writing anything.", "File paths pasted with quotes or escaped spaces are handled automatically — no manual cleanup needed.", "Slash commands never trigger the large-paste collapse — /command with big arguments works correctly.", -
hermes_cli/web_server.py@@ -232,7 +232,6 @@ async def auth_middleware(request: Request, call_next): "checkpoints": "agent", "approvals": "security", "human_delay": "display", - "smart_model_routing": "agent", "dashboard": "display", "code_execution": "agent", } -
skills/autonomous-ai-agents/hermes-agent/SKILL.md@@ -338,7 +338,6 @@ Edit with `hermes config edit` or `hermes config set section.key value`. | `memory` | `memory_enabled`, `user_profile_enabled`, `provider` | | `security` | `tirith_enabled`, `website_blocklist` | | `delegation` | `model`, `provider`, `base_url`, `api_key`, `max_iterations` (50), `reasoning_effort` | -| `smart_model_routing` | `enabled`, `cheap_model` | | `checkpoints` | `enabled`, `max_snapshots` (50) | Full config reference: https://hermes-agent.nousresearch.com/docs/user-guide/configuration -
tests/agent/test_credential_pool_routing.py@@ -1,129 +1,25 @@ -"""Tests for credential pool preservation through smart routing and 429 recovery. +"""Tests for credential pool preservation through turn config and 429 recovery. Covers: -1. credential_pool flows through resolve_turn_route (no-route and fallback paths) -2. CLI _resolve_turn_agent_config passes credential_pool to primary dict -3. Gateway _resolve_turn_agent_config passes credential_pool to primary dict -4. Eager fallback deferred when credential pool has credentials -5. Eager fallback fires when no credential pool exists -6. Full 429 rotation cycle: retry-same → rotate → exhaust → fallback +1. CLI _resolve_turn_agent_config passes credential_pool to runtime dict +2. Gateway _resolve_turn_agent_config passes credential_pool to runtime dict +3. Eager fallback deferred when credential pool has credentials +4. Eager fallback fires when no credential pool exists +5. Full 429 rotation cycle: retry-same → rotate → exhaust → fallback """ -import os -import time from types import SimpleNamespace -from unittest.mock import MagicMock, patch, PropertyMock - -import pytest - - -# --------------------------------------------------------------------------- -# 1. smart_model_routing: credential_pool preserved in no-route path -# --------------------------------------------------------------------------- - -class TestSmartRoutingPoolPreservation: - def test_no_route_preserves_credential_pool(self): - from agent.smart_model_routing import resolve_turn_route - - fake_pool = MagicMock(name="CredentialPool") - primary = { - "model": "gpt-5.4", - "api_key": "sk-test", - "base_url": None, - "provider": "openai-codex", - "api_mode": "codex_responses", - "command": None, - "args": [], - "credential_pool": fake_pool, - } - # routing disabled - result = resolve_turn_route("hello", None, primary) - assert result["runtime"]["credential_pool"] is fake_pool - - def test_no_route_none_pool(self): - from agent.smart_model_routing import resolve_turn_route - - primary = { - "model": "gpt-5.4", - "api_key": "sk-test", - "base_url": None, - "provider": "openai-codex", - "api_mode": "codex_responses", - "command": None, - "args": [], - } - result = resolve_turn_route("hello", None, primary) - assert result["runtime"]["credential_pool"] is None - - def test_routing_disabled_preserves_pool(self): - from agent.smart_model_routing import resolve_turn_route - - fake_pool = MagicMock(name="CredentialPool") - primary = { - "model": "gpt-5.4", - "api_key": "sk-test", - "base_url": None, - "provider": "openai-codex", - "api_mode": "codex_responses", - "command": None, - "args": [], - "credential_pool": fake_pool, - } - # routing explicitly disabled - result = resolve_turn_route("hello", {"enabled": False}, primary) - assert result["runtime"]["credential_pool"] is fake_pool - - def test_route_fallback_on_resolve_error_preserves_pool(self, monkeypatch): - """When smart routing picks a cheap model but resolve_runtime_provider - fails, the fallback to primary must still include credential_pool.""" - from agent.smart_model_routing import resolve_turn_route - - fake_pool = MagicMock(name="CredentialPool") - primary = { - "model": "gpt-5.4", - "api_key": "sk-test", - "base_url": None, - "provider": "openai-codex", - "api_mode": "codex_responses", - "command": None, - "args": [], - "credential_pool": fake_pool, - } - routing_config = { - "enabled": True, - "cheap_model": "openai/gpt-4.1-mini", - "cheap_provider": "openrouter", - "max_tokens": 200, - "patterns": ["^(hi|hello|hey)"], - } - # Force resolve_runtime_provider to fail so it falls back to primary - monkeypatch.setattr( - "hermes_cli.runtime_provider.resolve_runtime_provider", - MagicMock(side_effect=RuntimeError("no credentials")), - ) - result = resolve_turn_route("hi", routing_config, primary) - assert result["runtime"]["credential_pool"] is fake_pool +from unittest.mock import MagicMock, patch # --------------------------------------------------------------------------- -# 2 & 3. CLI and Gateway _resolve_turn_agent_config include credential_pool +# 1. CLI _resolve_turn_agent_config includes credential_pool # --------------------------------------------------------------------------- class TestCliTurnRoutePool: - def test_resolve_turn_includes_pool(self, monkeypatch, tmp_path): - """CLI's _resolve_turn_agent_config must pass credential_pool to primary.""" - from agent.smart_model_routing import resolve_turn_route - captured = {} - - def spy_resolve(user_message, routing_config, primary): - captured["primary"] = primary - return resolve_turn_route(user_message, routing_config, primary) - - monkeypatch.setattr( - "agent.smart_model_routing.resolve_turn_route", spy_resolve - ) - - # Build a minimal HermesCLI-like object with the method + def test_resolve_turn_includes_pool(self): + """CLI's _resolve_turn_agent_config must pass credential_pool in runtime.""" + fake_pool = MagicMock(name="FakePool") shell = SimpleNamespace( model="gpt-5.4", api_key="[redacted:secret-assignment]", @@ -132,58 +28,46 @@ def spy_resolve(user_message, routing_config, primary): api_mode="codex_responses", acp_command=None, acp_args=[], - _credential_pool=[redacted:secret-assignment]"FakePool"), - _smart_model_routing={"enabled": False}, + _credential_pool=[redacted:secret-assignment] + service_tier=None, ) - # Import and bind the real method from cli import HermesCLI bound = HermesCLI._resolve_turn_agent_config.__get__(shell) - bound("test message") + route = bound("test message") - assert "credential_pool" in captured["primary"] - assert captured["primary"]["credential_pool"] is shell._credential_pool + assert route["runtime"]["credential_pool"] is fake_pool +# --------------------------------------------------------------------------- +# 2. Gateway _resolve_turn_agent_config includes credential_pool +# --------------------------------------------------------------------------- + class TestGatewayTurnRoutePool: - def test_resolve_turn_includes_pool(self, monkeypatch): + def test_resolve_turn_includes_pool(self): """Gateway's _resolve_turn_agent_config must pass credential_pool.""" - from agent.smart_model_routing import resolve_turn_route - captured = {} - - def spy_resolve(user_message, routing_config, primary): - captured["primary"] = primary - return resolve_turn_route(user_message, routing_config, primary) - - monkeypatch.setattr( - "agent.smart_model_routing.resolve_turn_route", spy_resolve - ) - from gateway.run import GatewayRunner - runner = SimpleNamespace( - _smart_model_routing={"enabled": False}, - ) - + fake_pool = MagicMock(name="FakePool") + runner = SimpleNamespace(_service_tier=None) runtime_kwargs = { - "api_key": "sk-test", + "api_key": "***", "base_url": None, "provider": "openai-codex", "api_mode": "codex_responses", "command": None, "args": [], - "credential_pool": MagicMock(name="FakePool"), + "credential_pool": fake_pool, } bound = GatewayRunner._resolve_turn_agent_config.__get__(runner) - bound("test message", "gpt-5.4", runtime_kwargs) + route = bound("test message", "gpt-5.4", runtime_kwargs) - assert "credential_pool" in captured["primary"] - assert captured["primary"]["credential_pool"] is runtime_kwargs["credential_pool"] + assert route["runtime"]["credential_pool"] is fake_pool # --------------------------------------------------------------------------- -# 4 & 5. Eager fallback deferred/fires based on credential pool +# 3 & 4. Eager fallback deferred/fires based on credential pool # --------------------------------------------------------------------------- class TestEagerFallbackWithPool: @@ -251,7 +135,7 @@ def test_eager_fallback_fires_when_pool_exhausted(self): # --------------------------------------------------------------------------- -# 6. Full 429 rotation cycle via _recover_with_credential_pool +# 5. Full 429 rotation cycle via _recover_with_credential_pool # --------------------------------------------------------------------------- class TestPoolRotationCycle: -
/dev/null@@ -1,61 +0,0 @@ -from agent.smart_model_routing import choose_cheap_model_route - - -_BASE_CONFIG = { - "enabled": True, - "cheap_model": { - "provider": "openrouter", - "model": "google/gemini-2.5-flash", - }, -} - - -def test_returns_none_when_disabled(): - cfg = {**_BASE_CONFIG, "enabled": False} - assert choose_cheap_model_route("what time is it in tokyo?", cfg) is None - - -def test_routes_short_simple_prompt(): - result = choose_cheap_model_route("what time is it in tokyo?", _BASE_CONFIG) - assert result is not None - assert result["provider"] == "openrouter" - assert result["model"] == "google/gemini-2.5-flash" - assert result["routing_reason"] == "simple_turn" - - -def test_skips_long_prompt(): - prompt = "please summarize this carefully " * 20 - assert choose_cheap_model_route(prompt, _BASE_CONFIG) is None - - -def test_skips_code_like_prompt(): - prompt = "debug this traceback: ```python\nraise ValueError('bad')\n```" - assert choose_cheap_model_route(prompt, _BASE_CONFIG) is None - - -def test_skips_tool_heavy_prompt_keywords(): - prompt = "implement a patch for this docker error" - assert choose_cheap_model_route(prompt, _BASE_CONFIG) is None - - -def test_resolve_turn_route_falls_back_to_primary_when_route_runtime_cannot_be_resolved(monkeypatch): - from agent.smart_model_routing import resolve_turn_route - - monkeypatch.setattr( - "hermes_cli.runtime_provider.resolve_runtime_provider", - lambda **kwargs: (_ for _ in ()).throw(RuntimeError("bad route")), - ) - result = resolve_turn_route( - "what time is it in tokyo?", - _BASE_CONFIG, - { - "model": "anthropic/claude-sonnet-4", - "provider": "openrouter", - "base_url": "https://openrouter.ai/api/v1", - "api_mode": "chat_completions", - "api_key": "sk-primary", - }, - ) - assert result["model"] == "anthropic/claude-sonnet-4" - assert result["runtime"]["provider"] == "openrouter" - assert result["label"] is None -
tests/cli/test_cli_provider_resolution.py@@ -207,48 +207,11 @@ def test_cli_turn_routing_uses_primary_when_disabled(monkeypatch): shell.api_mode = "chat_completions" shell.base_url = "https://openrouter.ai/api/v1" shell.api_key = "[redacted:secret-assignment]" - shell._smart_model_routing = {"enabled": False} result = shell._resolve_turn_agent_config("what time is it in tokyo?") assert result["model"] == "gpt-5" assert result["runtime"]["provider"] == "openrouter" - assert result["label"] is None - - -def test_cli_turn_routing_uses_cheap_model_when_simple(monkeypatch): - cli = _import_cli() - - def _runtime_resolve(**kwargs): - assert kwargs["requested"] == "zai" - return { - "provider": "zai", - "api_mode": "chat_completions", - "base_url": "https://open.z.ai/api/v1", - "api_key": "cheap-key", - "source": "env/config", - } - - monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve) - - shell = cli.HermesCLI(model="anthropic/claude-sonnet-4", compact=True, max_turns=1) - shell.provider = "openrouter" - shell.api_mode = "chat_completions" - shell.base_url = "https://openrouter.ai/api/v1" - shell.api_key = "[redacted:secret-assignment]" - shell._smart_model_routing = { - "enabled": True, - "cheap_model": {"provider": "zai", "model": "glm-5-air"}, - "max_simple_chars": 160, - "max_simple_words": 28, - } - - result = shell._resolve_turn_agent_config("what time is it in tokyo?") - - assert result["model"] == "glm-5-air" - assert result["runtime"]["provider"] == "zai" - assert result["runtime"]["api_key"] == "cheap-key" - assert result["label"] is not None def test_cli_prefers_config_provider_over_stale_env_override(monkeypatch): -
tests/cli/test_fast_command.py@@ -183,27 +183,10 @@ def test_turn_route_injects_overrides_without_provider_switch(self): acp_command=None, acp_args=[], _credential_pool=None, - _smart_model_routing={}, service_tier="priority", ) - original_runtime = { - "api_key": "***", - "base_url": "https://openrouter.ai/api/v1", - "provider": "openrouter", - "api_mode": "chat_completions", - "command": None, - "args": [], - "credential_pool": None, - } - - with patch("agent.smart_model_routing.resolve_turn_route", return_value={ - "model": "gpt-5.4", - "runtime": dict(original_runtime), - "label": None, - "signature": ("gpt-5.4", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()), - }): - route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi") + route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi") # Provider should NOT have changed assert route["runtime"]["provider"] == "openrouter" @@ -222,26 +205,10 @@ def test_turn_route_keeps_primary_runtime_when_model_has_no_fast_backend(self): acp_command=None, acp_args=[], _credential_pool=None, - _smart_model_routing={}, service_tier="priority", ) - primary_route = { - "model": "gpt-5.3-codex", - "runtime": { - "api_key": "***", - "base_url": "https://openrouter.ai/api/v1", - "provider": "openrouter", - "api_mode": "chat_completions", - "command": None, - "args": [], - "credential_pool": None, - }, - "label": None, - "signature": ("gpt-5.3-codex", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()), - } - with patch("agent.smart_model_routing.resolve_turn_route", return_value=primary_route): - route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi") + route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi") assert route["runtime"]["provider"] == "openrouter" assert route.get("request_overrides") is None @@ -329,27 +296,10 @@ def test_turn_route_injects_speed_for_anthropic(self): acp_command=None, acp_args=[], _credential_pool=None, - _smart_model_routing={}, service_tier="priority", ) - original_runtime = { - "api_key": "***", - "base_url": "https://api.anthropic.com", - "provider": "anthropic", - "api_mode": "anthropic_messages", - "command": None, - "args": [], - "credential_pool": None, - } - - with patch("agent.smart_model_routing.resolve_turn_route", return_value={ - "model": "claude-opus-4-6", - "runtime": dict(original_runtime), - "label": None, - "signature": ("claude-opus-4-6", "anthropic", "https://api.anthropic.com", "anthropic_messages", None, ()), - }): - route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi") + route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi") assert route["runtime"]["provider"] == "anthropic" assert route["request_overrides"] == {"speed": "fast"} -
tests/cron/test_codex_execution_paths.py@@ -152,7 +152,6 @@ def test_gateway_run_agent_codex_path_handles_internal_401_refresh(monkeypatch): runner._provider_routing = {} runner._fallback_model = None runner._running_agents = {} - runner._smart_model_routing = {} from unittest.mock import MagicMock, AsyncMock runner.hooks = MagicMock() runner.hooks.emit = AsyncMock() -
tests/gateway/test_discord_channel_prompts.py@@ -75,7 +75,6 @@ def _make_runner(): runner._service_tier = None runner._provider_routing = {} runner._fallback_model = None - runner._smart_model_routing = {} runner._running_agents = {} runner._pending_model_notes = {} runner._session_db = None -
tests/gateway/test_fast_command.py@@ -4,7 +4,7 @@ import threading import types from types import SimpleNamespace -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest import yaml @@ -53,7 +53,6 @@ def _make_runner(): runner._service_tier = None runner._provider_routing = {} runner._fallback_model = None - runner._smart_model_routing = {} runner._running_agents = {} runner._pending_model_notes = {} runner._session_db = None @@ -97,13 +96,7 @@ def test_turn_route_injects_priority_processing_without_changing_runtime(): "credential_pool": None, } - with patch("agent.smart_model_routing.resolve_turn_route", return_value={ - "model": "gpt-5.4", - "runtime": dict(runtime_kwargs), - "label": None, - "signature": ("gpt-5.4", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()), - }): - route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.4", runtime_kwargs) + route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.4", runtime_kwargs) assert route["runtime"]["provider"] == "openrouter" assert route["runtime"]["api_mode"] == "chat_completions" @@ -123,13 +116,7 @@ def test_turn_route_skips_priority_processing_for_unsupported_models(): "credential_pool": None, } - with patch("agent.smart_model_routing.resolve_turn_route", return_value={ - "model": "gpt-5.3-codex", - "runtime": dict(runtime_kwargs), - "label": None, - "signature": ("gpt-5.3-codex", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()), - }): - route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.3-codex", runtime_kwargs) + route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.3-codex", runtime_kwargs) assert route["request_overrides"] is None -
website/docs/integrations/providers.md@@ -1165,39 +1165,6 @@ Supported providers: `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-a Fallback is configured exclusively through `config.yaml` — there are no environment variables for it. For full details on when it triggers, supported providers, and how it interacts with auxiliary tasks and delegation, see [Fallback Providers](/docs/user-guide/features/fallback-providers). ::: -## Smart Model Routing - -Optional cheap-vs-strong routing lets Hermes keep your main model for complex work while sending very short/simple turns to a cheaper model. - -```yaml -smart_model_routing: - enabled: true - max_simple_chars: 160 - max_simple_words: 28 - cheap_model: - provider: openrouter - model: google/gemini-2.5-flash - # base_url: http://localhost:8000/v1 # optional custom endpoint - # key_env: MY_CUSTOM_KEY # optional env var name for that endpoint's API key -``` - -How it works: -- If a turn is short, single-line, and does not look code/tool/debug heavy, Hermes may route it to `cheap_model` -- If the turn looks complex, Hermes stays on your primary model/provider -- If the cheap route cannot be resolved cleanly, Hermes falls back to the primary model automatically - -This is intentionally conservative. It is meant for quick, low-stakes turns like: -- short factual questions -- quick rewrites -- lightweight summaries - -It will avoid routing prompts that look like: -- coding/debugging work -- tool-heavy requests -- long or multi-line analysis asks - -Use this when you want lower latency or cost without fully changing your default model. - --- ## See Also
-
-
#16099 fix(run_agent): background review fork inherits parent's live runtime
files:
run_agent.pyShow the diff
-
run_agent.py@@ -3154,12 +3154,25 @@ def _run_review(): with open(os.devnull, "w") as _devnull, \ contextlib.redirect_stdout(_devnull), \ contextlib.redirect_stderr(_devnull): + # Inherit the parent agent's live runtime (provider, model, + # base_url, api_key, api_mode) so the fork uses the exact + # same credentials the main turn is using. Without this, + # AIAgent.__init__ re-runs auto-resolution from env vars, + # which fails for OAuth-only providers, session-scoped + # creds, or credential-pool setups where the resolver can't + # reconstruct auth from scratch -- producing the spurious + # "No LLM provider configured" warning at end of turn. + _parent_runtime = self._current_main_runtime() review_agent = AIAgent( model=self.model, max_iterations=8, quiet_mode=True, platform=self.platform, provider=self.provider, + api_mode=_parent_runtime.get("api_mode") or None, + base_url=_parent_runtime.get("base_url") or None, + api_key=[redacted:secret-assignment]"api_key") or None, + credential_pool=[redacted:secret-assignment] "_credential_pool", None), parent_session_id=self.session_id, ) review_agent._memory_write_origin = "background_review"
-
1 PR closed without merging
From the close comments: done elsewhere 1
-
#8351 fix(memory): use primary model for background memory/skill review · done elsewhere →
424e9f3,e3901d5,#12732,#16099
Closed issues · 3
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.25: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab