diff --git a/agent/agent_init.py b/agent/agent_init.py index 5b78192587..e482570b8d 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -2830,6 +2830,12 @@ def init_agent( agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction agent.codex_responses_native_compaction = codex_responses_native_compaction agent.codex_responses_compact_threshold = codex_responses_compact_threshold + from agent.native_compaction import resolve_native_compaction_capabilities + agent.capabilities = resolve_native_compaction_capabilities( + model=agent.model, + base_url=agent.base_url, + is_codex_backend=(agent.provider or "").strip().lower() == "openai-codex", + ) agent.max_compression_attempts = compression_max_attempts agent.compression_idle_compact_after_seconds = ( compression_idle_compact_after_seconds diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 74dc486da2..fabfe751e7 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1748,6 +1748,8 @@ def restore_primary_runtime(agent) -> bool: if hasattr(agent, "_transport_cache"): agent._transport_cache.clear() agent.api_key = rt["api_key"] + if "capabilities" in rt: + agent.capabilities = dict(rt.get("capabilities") or {}) agent._reasoning_echo_flag = rt.get("reasoning_echo_flag", False) agent.request_overrides = dict(rt.get("request_overrides") or {}) agent._client_kwargs = dict(rt["client_kwargs"]) @@ -2895,7 +2897,15 @@ def _apply_switched_provider_request_overrides(agent, new_provider): agent.request_overrides = overrides -def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mode=''): +def switch_model( + agent, + new_model, + new_provider, + api_key='', + base_url='', + api_mode='', + capabilities=None, +): """Switch the model/provider in-place for a live agent. Called by the /model command handlers (CLI and gateway) after @@ -2910,6 +2920,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo turn-scoped). """ from hermes_cli.providers import determine_api_mode + from agent.native_compaction import resolve_native_compaction_capabilities # ── Determine api_mode if not provided ── # Pass model so dual-wire providers (Nous Portal anthropic/* → Messages) @@ -2918,6 +2929,16 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo if not api_mode: api_mode = determine_api_mode(new_provider, base_url, model=new_model) + destination_capabilities = ( + dict(capabilities) + if isinstance(capabilities, dict) + else resolve_native_compaction_capabilities( + model=new_model, + base_url=base_url, + is_codex_backend=(new_provider or '').strip().lower() == 'openai-codex', + ) + ) + # Defense-in-depth: ensure OpenCode base_url doesn't carry a trailing # /v1 into the anthropic_messages client, which would cause the SDK to # hit /v1/v1/messages. `model_switch.switch_model()` already strips @@ -2964,6 +2985,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo "_is_anthropic_oauth", "_config_context_length", "_reasoning_echo_flag", + "capabilities", ) } # _client_kwargs is a dict — snapshot a shallow copy so mutating the @@ -3180,9 +3202,13 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo except Exception: _destination_context_intent = None agent._config_context_length = _destination_context_intent - _runtime_context_length = agent._ensure_lmstudio_runtime_loaded( - _destination_context_intent - ) + try: + _runtime_context_length = agent._ensure_lmstudio_runtime_loaded( + _destination_context_intent + ) + except Exception: + _restore_snapshot() + raise if agent._lmstudio_load_was_unverified(_runtime_context_length): logger.warning( "LM Studio model activation was rejected or completed without a " @@ -3226,22 +3252,26 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo # length normally resolves via config or static catalogs and # never hits a probe, but coerce to empty string defensively. _ctx_api_key = agent.api_key if isinstance(agent.api_key, str) else "" - new_context_length = get_model_context_length( - agent.model, - base_url=agent.base_url, - api_key=_ctx_api_key, - provider=agent.provider, - config_context_length=_effective_context_length, - custom_providers=_sm_custom_providers, - ) - agent.context_compressor.update_model( - model=agent.model, - context_length=new_context_length, - base_url=agent.base_url, - api_key=agent.api_key, # context_compressor forwards to call_llm; callable preserved - provider=agent.provider, - api_mode=agent.api_mode, - ) + try: + new_context_length = get_model_context_length( + agent.model, + base_url=agent.base_url, + api_key=_ctx_api_key, + provider=agent.provider, + config_context_length=_effective_context_length, + custom_providers=_sm_custom_providers, + ) + agent.context_compressor.update_model( + model=agent.model, + context_length=new_context_length, + base_url=agent.base_url, + api_key=agent.api_key, # context_compressor forwards to call_llm; callable preserved + provider=agent.provider, + api_mode=agent.api_mode, + ) + except Exception: + _restore_snapshot() + raise # ── Re-resolve reasoning_config from per-model override ── # The new model may have a different reasoning_effort override. Re-read @@ -3264,6 +3294,10 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo # ── Invalidate cached system prompt so it rebuilds next turn ── agent._cached_system_prompt = None + # Publish the destination capability map only after every runtime setup + # above has succeeded. Failed switches must leave the old map intact. + agent.capabilities = destination_capabilities + # ── Reset the cross-turn stale-call circuit breaker (#58962) ── # The breaker's error text tells the user to "switch models ... then # retry"; without this reset the streak stays latched and the freshly @@ -3291,6 +3325,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo # recovery or fallback restore would resurrect the PRE-switch # overrides via the stale init-time snapshot (#75091 seam). "request_overrides": dict(getattr(agent, "request_overrides", {}) or {}), + "capabilities": dict(getattr(agent, "capabilities", {}) or {}), "compressor_model": getattr(_cc, "model", agent.model) if _cc else agent.model, "compressor_base_url": getattr(_cc, "base_url", agent.base_url) if _cc else agent.base_url, "compressor_api_key": getattr(_cc, "api_key", "") if _cc else "", diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 42f186184c..009f6a5690 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2932,6 +2932,12 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool # short-circuit the freshly activated fallback before it gets a # single stream attempt. _reset_stale_streak(agent) + from agent.native_compaction import resolve_native_compaction_capabilities + agent.capabilities = resolve_native_compaction_capabilities( + model=agent.model, + base_url=agent.base_url, + is_codex_backend=fb_provider == "openai-codex", + ) return True except Exception as e: if fb_provider == "nous": diff --git a/agent/native_compaction.py b/agent/native_compaction.py index ba98ea2a48..8d098adf4f 100644 --- a/agent/native_compaction.py +++ b/agent/native_compaction.py @@ -68,6 +68,24 @@ def is_native_compaction_model(model: Optional[str]) -> bool: return _ELIGIBLE_MODEL_MARKER in (model or "").lower() +def resolve_native_compaction_capabilities( + *, + model: Optional[str], + base_url: Optional[str], + is_codex_backend: bool = False, +) -> Dict[str, bool]: + """Resolve the native-compaction capability for a runtime destination. + + The result is deliberately explicit: a resolved ``False`` is different + from an unresolved capability and must survive model switches unchanged. + """ + eligible = is_native_compaction_model(model) and is_direct_openai_route( + base_url, + is_codex_backend=is_codex_backend, + ) + return {"native_compaction": eligible} + + def is_direct_openai_route( base_url: Optional[str], *, @@ -160,6 +178,10 @@ def native_compaction_context_management( (``agent.codex_responses_native_compaction = False``, set by the conversation loop's rejection recovery) takes effect on the next call. """ + capabilities = getattr(agent, "capabilities", None) + if isinstance(capabilities, dict): + if not bool(capabilities.get("native_compaction", False)): + return None if not bool(getattr(agent, "codex_responses_native_compaction", False)): return None # compression.enabled: false disables ALL automatic compaction, native diff --git a/cli.py b/cli.py index bcf71903eb..f92fd5d4cb 100644 --- a/cli.py +++ b/cli.py @@ -10306,6 +10306,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): api_key=_reset_result.api_key, base_url=_reset_result.base_url, api_mode=_reset_result.api_mode, + capabilities=_reset_result.runtime_capabilities, ) self.model = _reset_result.new_model self.provider = _reset_result.target_provider @@ -11480,6 +11481,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): api_key=snapshot.get("api_key", ""), base_url=snapshot.get("base_url", ""), api_mode=snapshot.get("api_mode", ""), + capabilities=snapshot.get("capabilities"), ) except Exception as exc: logger.warning("CLI one-turn model restore failed: %s", exc) @@ -11624,6 +11626,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): api_key=result.api_key, base_url=result.base_url, api_mode=result.api_mode, + capabilities=result.runtime_capabilities, ) except Exception as exc: # The agent rolled itself back to the old working model/client. @@ -12014,6 +12017,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): api_key=result.api_key, base_url=result.base_url, api_mode=result.api_mode, + capabilities=result.runtime_capabilities, ) except Exception as exc: # Agent rolled itself back; roll the CLI back too and abort so a diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 3279f40439..b8ce7cc616 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -1950,6 +1950,7 @@ class GatewaySlashCommandsMixin: api_key=result.api_key, base_url=result.base_url, api_mode=result.api_mode, + capabilities=result.runtime_capabilities, ) except Exception as exc: # The in-place swap rolled the agent back to the @@ -2264,6 +2265,7 @@ class GatewaySlashCommandsMixin: api_key=result.api_key, base_url=result.base_url, api_mode=result.api_mode, + capabilities=result.runtime_capabilities, ) except Exception as exc: # In-place swap rolled the agent back to the OLD working diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index c50df9e104..e5db94f47a 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -626,6 +626,7 @@ class ModelSwitchResult: provider_label: str = "" resolved_via_alias: str = "" capabilities: Optional[ModelCapabilities] = None + runtime_capabilities: Optional[dict[str, bool]] = None model_info: Optional[ModelInfo] = None is_global: bool = False @@ -2173,6 +2174,12 @@ def switch_model( # --- Get capabilities (legacy) --- capabilities = get_model_capabilities(target_provider, new_model, allow_network=True) + from agent.native_compaction import resolve_native_compaction_capabilities + runtime_capabilities = resolve_native_compaction_capabilities( + model=new_model, + base_url=base_url, + is_codex_backend=target_provider.strip().lower() == "openai-codex", + ) # --- Get full model info from models.dev --- model_info = get_model_info(target_provider, new_model, allow_network=True) @@ -2215,6 +2222,7 @@ def switch_model( provider_label=provider_label, resolved_via_alias=resolved_alias, capabilities=capabilities, + runtime_capabilities=runtime_capabilities, model_info=model_info, is_global=is_global, ) diff --git a/run_agent.py b/run_agent.py index 1b87729159..ecec7b9b82 100644 --- a/run_agent.py +++ b/run_agent.py @@ -899,10 +899,26 @@ class AIAgent: return_load_result=True, ) - def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mode=''): + def switch_model( + self, + new_model, + new_provider, + api_key='', + base_url='', + api_mode='', + capabilities=None, + ): """Forwarder — see ``agent.agent_runtime_helpers.switch_model``.""" from agent.agent_runtime_helpers import switch_model - return switch_model(self, new_model, new_provider, api_key, base_url, api_mode) + return switch_model( + self, + new_model, + new_provider, + api_key, + base_url, + api_mode, + capabilities, + ) def _safe_print(self, *args, **kwargs): """Print that silently handles broken pipes / closed stdout. diff --git a/tests/run_agent/test_fallback_reasoning_override.py b/tests/run_agent/test_fallback_reasoning_override.py index 911e8205d6..349508942d 100644 --- a/tests/run_agent/test_fallback_reasoning_override.py +++ b/tests/run_agent/test_fallback_reasoning_override.py @@ -75,6 +75,7 @@ class TestFallbackReasoningOverride: "client_kwargs": {}, "use_prompt_caching": False, "use_native_cache_layout": False, + "capabilities": {"native_compaction": True}, "reasoning_config": {"enabled": True, "effort": "medium"}, "compressor_model": "gemini-flash", "compressor_base_url": "", @@ -95,6 +96,7 @@ class TestFallbackReasoningOverride: agent.model = "claude-opus-4.5" agent.provider = "anthropic" agent.reasoning_config = {"enabled": True, "effort": "xhigh"} + agent.capabilities = {"native_compaction": False} agent.context_compressor = MagicMock() agent.base_url = "" agent._anthropic_prompt_cache_policy = MagicMock(return_value=(False, False)) @@ -105,6 +107,7 @@ class TestFallbackReasoningOverride: assert result is True # reasoning_config should be restored to primary's value (medium) assert agent.reasoning_config == {"enabled": True, "effort": "medium"} + assert agent.capabilities == {"native_compaction": True} def test_fallback_global_fallback_with_yaml_false(self): """Fallback global fallback must not coerce YAML boolean False. diff --git a/tests/run_agent/test_native_compaction_switch_capabilities.py b/tests/run_agent/test_native_compaction_switch_capabilities.py new file mode 100644 index 0000000000..dde00c4bea --- /dev/null +++ b/tests/run_agent/test_native_compaction_switch_capabilities.py @@ -0,0 +1,48 @@ +from types import SimpleNamespace + +from agent.native_compaction import ( + native_compaction_context_management, + resolve_native_compaction_capabilities, +) + + +def _agent(capabilities): + return SimpleNamespace( + model="gpt-5.6", + base_url="https://proxy.example/v1", + codex_responses_native_compaction=True, + compression_enabled=True, + codex_responses_compact_threshold=200_000, + context_compressor=None, + capabilities=capabilities, + ) + + +def test_trusted_destination_capabilities_are_explicitly_enabled(): + capabilities = resolve_native_compaction_capabilities( + model="gpt-5.6", + base_url="https://api.openai.com/v1", + ) + + assert capabilities == {"native_compaction": True} + + +def test_untrusted_destination_capabilities_are_explicitly_denied(): + capabilities = resolve_native_compaction_capabilities( + model="gpt-5.6", + base_url="https://openrouter.ai/api/v1", + ) + + assert capabilities == {"native_compaction": False} + + +def test_explicit_false_capability_denies_native_payload(): + agent = _agent({"native_compaction": False}) + + assert native_compaction_context_management(agent, is_codex_backend=False) is None + + +def test_missing_capability_keeps_default_deny(): + agent = _agent({}) + + assert native_compaction_context_management(agent, is_codex_backend=False) is None diff --git a/tests/run_agent/test_switch_model_context.py b/tests/run_agent/test_switch_model_context.py index 22321fdc00..8684baa700 100644 --- a/tests/run_agent/test_switch_model_context.py +++ b/tests/run_agent/test_switch_model_context.py @@ -284,3 +284,30 @@ def test_lmstudio_switch_uses_destination_context_and_verified_runtime(monkeypat assert call_kwargs.get("config_context_length") == 100_000 assert agent._config_context_length == 120_000 assert agent.context_compressor.context_length == 100_000 + + +def test_later_lmstudio_failure_restores_runtime_capabilities(monkeypatch): + agent = _make_agent_with_compressor(config_context_length=32_768) + agent.capabilities = {"native_compaction": True} + original_client = agent.client + + monkeypatch.setattr( + AIAgent, + "_ensure_lmstudio_runtime_loaded", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + RuntimeError("simulated LM Studio failure") + ), + ) + + with pytest.raises(RuntimeError, match="simulated LM Studio failure"): + agent.switch_model( + "new-model", + "openrouter", + api_key="sk-new", + base_url="https://openrouter.ai/api/v1", + ) + + assert agent.model == "primary-model" + assert agent.provider == "openrouter" + assert agent.client is original_client + assert agent.capabilities == {"native_compaction": True} diff --git a/tests/run_agent/test_switch_model_rollback.py b/tests/run_agent/test_switch_model_rollback.py index efedad9895..8f8f7686a9 100644 --- a/tests/run_agent/test_switch_model_rollback.py +++ b/tests/run_agent/test_switch_model_rollback.py @@ -46,6 +46,7 @@ def _make_agent_openrouter(): agent._fallback_chain = [] agent._fallback_model = None agent._config_context_length = None + agent.capabilities = {"native_compaction": False} return agent @@ -73,6 +74,7 @@ def _make_agent_anthropic(): agent._fallback_chain = [] agent._fallback_model = None agent._config_context_length = None + agent.capabilities = {"native_compaction": False} return agent @@ -108,6 +110,7 @@ def test_openai_client_rebuild_failure_rolls_back_to_original_state(): assert agent.api_key == "or-key-original" assert agent.client is original_client assert agent._client_kwargs == original_kwargs + assert agent.capabilities == {"native_compaction": False} def test_anthropic_client_rebuild_failure_rolls_back_to_original_state(): diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 576d6db45f..e4b70649ea 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -6135,6 +6135,7 @@ def _restore_agent_model_runtime(agent, snapshot: dict | None) -> None: api_key=snapshot.get("api_key", ""), base_url=snapshot.get("base_url", ""), api_mode=snapshot.get("api_mode", ""), + capabilities=snapshot.get("capabilities"), ) @@ -6347,6 +6348,7 @@ def _apply_model_switch( api_key=result.api_key, base_url=result.base_url, api_mode=result.api_mode, + capabilities=result.runtime_capabilities, ) except Exception as exc: # The in-place swap rolled the agent back to the old working