From cb4daf23f36367186d59f29dc06ea41390bb0329 Mon Sep 17 00:00:00 2001 From: Ario Bagus Prakusa Date: Tue, 11 Aug 2026 20:01:38 +0700 Subject: [PATCH] fix: coerce dict-valued model/default config back to string across resolution paths A dict-valued model.default (e.g. {provider:..., model:...}) in config.yaml was leaking into agent.model and crashing the agent at init: AttributeError: 'dict' object has no attribute 'lower' agent/agent_runtime_helpers.py: anthropic_prompt_cache_policy This manifested on the Telegram gateway as an infinite reset loop: every turn built an agent with model=dict, crashed during init, the gateway treated the failed turn as a session needing reset, and /reset rebuilt the agent and crashed again. Coerce dict -> string at every model-resolution entry point so the value is normalized once and never reaches a .lower() call as a dict: - agent/agent_runtime_helpers.py: anthropic_prompt_cache_policy (the crash site) - agent/agent_init.py: configured default model resolution - cli.py: CLI config model + _normalize_model_for_provider - hermes_cli/main.py: _has_any_provider_configured - hermes_cli/oneshot.py: _run_agent model resolution - hermes_cli/runtime_provider.py: _get_model_config default handling - model_tools.py: _resolve_active_context_length --- agent/agent_init.py | 5 ++++- agent/agent_runtime_helpers.py | 3 +++ cli.py | 25 ++++++++++++++++--------- hermes_cli/main.py | 7 ++++++- hermes_cli/oneshot.py | 6 +++++- hermes_cli/runtime_provider.py | 11 ++++++++++- model_tools.py | 5 ++++- 7 files changed, 48 insertions(+), 14 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 68fa17b0df..466869a0b5 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -2272,7 +2272,10 @@ def init_agent( # overrides consistent with them and let provider metadata resolve the # active model's window instead. if _config_context_length is not None and isinstance(_model_cfg, dict): - _configured_default_model = str(_model_cfg.get("default") or "").strip() + _default = _model_cfg.get("default") + if isinstance(_default, dict): + _default = str(_default.get("model") or _default.get("default") or "") + _configured_default_model = str(_default or "").strip() _configured_default_runtime_model = _configured_default_model _active_runtime_model = agent.model if _configured_default_model: diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index cb6ed2e15f..db25f428d6 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -2235,6 +2235,9 @@ def anthropic_prompt_cache_policy( logger.debug("MoA aggregator cache-policy resolution failed: %s", _moa_exc) return False, False + if isinstance(eff_model, dict): + eff_model = eff_model.get('default') or eff_model.get('model') or '' + eff_model = eff_model if isinstance(eff_model, str) else str(eff_model or '') model_lower = eff_model.lower() provider_lower = eff_provider.lower() is_claude = "claude" in model_lower diff --git a/cli.py b/cli.py index eb7c0da950..0a7e67f64c 100644 --- a/cli.py +++ b/cli.py @@ -4565,7 +4565,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # authoritative. This avoids conflicts in multi-agent setups where # env vars would stomp each other. _model_config = CLI_CONFIG.get("model", {}) - _config_model = (_model_config.get("default") or _model_config.get("model") or "") if isinstance(_model_config, dict) else (_model_config or "") + _raw_default = (_model_config.get("default") or _model_config.get("model") or "") if isinstance(_model_config, dict) else (_model_config or "") + if isinstance(_raw_default, dict): + _config_model = str(_raw_default.get("model") or _raw_default.get("default") or _raw_default.get("provider") or "") + else: + _config_model = str(_raw_default or "") _DEFAULT_CONFIG_MODEL = "" # Track whether the user passed -m / --model so resume knows not to # clobber an explicit override with the session's stored model. @@ -6659,7 +6663,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): def _normalize_model_for_provider(self, resolved_provider: str) -> bool: """Normalize provider-specific model IDs and routing.""" - current_model = (self.model or "").strip() + current_model = str(self.model or "").strip() + if isinstance(self.model, dict): + _m_dict = self.model + current_model = str(_m_dict.get("model") or _m_dict.get("default") or _m_dict.get("provider") or "").strip() changed = False try: @@ -8973,11 +8980,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): CLI_CONFIG["agent"].get("service_tier", "") ) _model_config = CLI_CONFIG.get("model", {}) - _config_model = ( - (_model_config.get("default") or _model_config.get("model") or "") - if isinstance(_model_config, dict) - else (_model_config or "") - ) + _raw_default2 = (_model_config.get("default") or _model_config.get("model") or "") if isinstance(_model_config, dict) else (_model_config or "") + if isinstance(_raw_default2, dict): + _config_model = str(_raw_default2.get("model") or _raw_default2.get("default") or _raw_default2.get("provider") or "") + else: + _config_model = str(_raw_default2 or "") if _config_model and _config_model != getattr(self, "model", None): _config_provider = ( _model_config.get("provider", "") @@ -14794,8 +14801,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): from hermes_cli.config import load_config _img_mode = decide_image_input_mode( - (self.provider or "").strip(), - (self.model or "").strip(), + str(self.provider or "").strip() if not isinstance(self.provider, dict) else str(self.provider.get("provider") or self.provider.get("default") or "").strip(), + str(self.model or "").strip() if not isinstance(self.model, dict) else str(self.model.get("model") or self.model.get("default") or "").strip(), load_config(), requested_provider=(self.requested_provider or "").strip(), ) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 22ea6e5972..3987ea458e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -973,7 +973,12 @@ def _has_any_provider_configured() -> bool: cfg = load_config() model_cfg = cfg.get("model") if isinstance(model_cfg, dict): - _model_name = (model_cfg.get("default") or "").strip() + _default = model_cfg.get("default") + if isinstance(_default, dict): + _model_name = (_default.get("model") or _default.get("provider") or "") + else: + _model_name = (_default or "") + _model_name = (str(_model_name) if not isinstance(_model_name, str) else _model_name).strip() elif isinstance(model_cfg, str): _model_name = model_cfg.strip() else: diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index 8aa6c9662d..f0fd2ab876 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -343,7 +343,11 @@ def _run_agent( if isinstance(model_cfg, str): cfg_model = model_cfg else: - cfg_model = model_cfg.get("default") or model_cfg.get("model") or "" + _raw = model_cfg.get("default") or model_cfg.get("model") or "" + if isinstance(_raw, dict): + cfg_model = str(_raw.get("model") or _raw.get("default") or _raw.get("provider") or "") + else: + cfg_model = str(_raw or "") env_model = os.getenv("HERMES_INFERENCE_MODEL", "").strip() effective_model = (model or "").strip() or env_model or cfg_model diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index dc1f70044c..955216f58d 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -322,7 +322,16 @@ def _get_model_config() -> Dict[str, Any]: # Accept "model" as alias for "default" (users intuitively write model.model) if not cfg.get("default") and cfg.get("model"): cfg["default"] = cfg["model"] - default = (cfg.get("default") or "").strip() + # Handle model.default being a dict {provider: ..., model: ...} rather than a string + _default = cfg.get("default") + if isinstance(_default, dict): + cfg_provider = str(_default.get("provider") or model_cfg.get("provider") or "") + cfg_model = str(_default.get("model") or _default.get("default") or "") + cfg["default"] = cfg_model + if cfg_provider and not cfg.get("provider"): + cfg["provider"] = cfg_provider + _default = cfg_model + default = (str(_default or "")).strip() base_url = (cfg.get("base_url") or "").strip() is_local = base_url_hostname(base_url) in ("localhost", "127.0.0.1") is_fallback = not default diff --git a/model_tools.py b/model_tools.py index a14b28e345..764d0d50d6 100644 --- a/model_tools.py +++ b/model_tools.py @@ -664,7 +664,10 @@ def _resolve_active_context_length() -> int: model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else {} if not isinstance(model_cfg, dict): model_cfg = {} - model_id = (model_cfg.get("model") or model_cfg.get("default") or "").strip() + _raw_model_id = model_cfg.get("model") or model_cfg.get("default") or "" + if isinstance(_raw_model_id, dict): + _raw_model_id = _raw_model_id.get("model") or _raw_model_id.get("provider") or "" + model_id = str(_raw_model_id).strip() if not model_id: return 0 from agent.model_metadata import get_model_context_length