From 522bdee4b4bddb048a8529118fbcc5e10a6c6c04 Mon Sep 17 00:00:00 2001 From: BlutAgent Date: Thu, 11 Jun 2026 09:09:37 -0500 Subject: [PATCH 1/2] fix(image_routing): check main model vision support before aux override When the main model supports vision (supports_vision=True), images now go through the native path even if auxiliary.vision is explicitly configured. The aux override is now a fallback for non-vision models only, not a blanket override that blocks native vision. This fixes the bug where providers like Xiaomi MiMo (which has supports_vision=True) were incorrectly routed to text-only mode when auxiliary.vision was configured. Also updates the module docstring to reflect the new priority order. --- agent/image_routing.py | 20 +++++++++++--------- tests/agent/test_image_routing.py | 19 +++++++++++++++---- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/agent/image_routing.py b/agent/image_routing.py index c8b3f6640c6dd..48ba24eff6d74 100644 --- a/agent/image_routing.py +++ b/agent/image_routing.py @@ -17,12 +17,13 @@ | ``text``, default ``auto``) and the active model's capability metadata. In ``auto`` mode: - - If the user has explicitly configured ``auxiliary.vision.provider`` + - If the active model reports ``supports_vision=True`` in its + models.dev metadata (or via the ``supports_vision`` config override), + we attach natively — the main model can see the pixels directly. + - Otherwise, if the user has explicitly configured ``auxiliary.vision.provider`` (i.e. not ``auto`` and not empty), we assume they want the text pipeline - regardless of the main model — they've opted in to a specific vision - backend for a reason (cost, quality, local-only, etc.). - - Otherwise, if the active model reports ``supports_vision=True`` in its - models.dev metadata, we attach natively. + as a fallback (they've opted in to a specific vision backend for a reason: + cost, quality, local-only, etc.). - Otherwise (non-vision model, no explicit override), we fall back to text. This keeps ``vision_analyze`` surfaced as a tool in every session — skills @@ -337,12 +338,13 @@ def decide_image_input_mode( return "text" # auto - if _explicit_aux_vision_override(cfg): - return "text" - supports = _lookup_supports_vision(provider, model, cfg) if supports is True: - return "native" + return "native" # main model can see images + + if _explicit_aux_vision_override(cfg): + return "text" # main model can't see -> use aux if configured + return "text" diff --git a/tests/agent/test_image_routing.py b/tests/agent/test_image_routing.py index b5a43f1ff0e30..97f5c78bf4dfd 100644 --- a/tests/agent/test_image_routing.py +++ b/tests/agent/test_image_routing.py @@ -279,15 +279,26 @@ def test_auto_text_for_custom_with_no_override(self): with patch("agent.models_dev.get_model_capabilities", return_value=None): assert decide_image_input_mode("custom", "unknown", {}) == "text" - def test_explicit_aux_vision_override_still_wins(self): - # If the user has configured a dedicated vision aux backend, respect - # it even when supports_vision: true is also set. + def test_explicit_aux_vision_override_only_applies_when_main_model_not_vision(self): + # Aux override is a fallback: when the main model supports vision, + # images go native regardless of aux config. The aux override only + # applies when the main model is non-vision. cfg = { "model": {"supports_vision": True}, "auxiliary": {"vision": {"provider": "openrouter", "model": "gemini-2.5-pro"}}, } with patch("agent.models_dev.get_model_capabilities", return_value=None): - assert decide_image_input_mode("custom", "qwen3.6-35b", cfg) == "text" + assert decide_image_input_mode("custom", "qwen3.6-35b", cfg) == "native" + + def test_aux_vision_override_wins_when_main_model_not_vision(self): + # When the main model does NOT support vision, the explicit aux + # override still forces text routing. + cfg = { + "model": {"supports_vision": False}, + "auxiliary": {"vision": {"provider": "openrouter", "model": "gemini-2.5-pro"}}, + } + with patch("agent.models_dev.get_model_capabilities", return_value=None): + assert decide_image_input_mode("custom", "text-only-model", cfg) == "text" # ─── build_native_content_parts ────────────────────────────────────────────── From 7d88c4335eeb99ba60045cfdf10def1aebd34da2 Mon Sep 17 00:00:00 2001 From: BlutAgent Date: Sat, 13 Jun 2026 09:07:15 -0500 Subject: [PATCH 2/2] fix(type-annotations): replace Optional[callable] with Optional[Callable[...]] in 6 files The builtin callable (lowercase) is not a valid generic type for static type checkers (mypy/pyright). Replace all 7 instances of Optional[callable] with the proper typing.Callable variant across: - run_agent.py (2 instances: run_conversation forwarder + chat method) - agent/conversation_loop.py (1 instance: run_conversation signature) - gateway/stream_consumer.py (1 instance: StreamConsumer.__init__) - hermes_cli/dingtalk_auth.py (1 instance: wait_for_registration_success) - tools/delegate_tool.py (1 instance: _build_child_progress_callback return) - plugins/disk-cleanup/disk_cleanup.py (1 instance: deep() confirm param) --- agent/conversation_loop.py | 4 ++-- gateway/stream_consumer.py | 2 +- hermes_cli/dingtalk_auth.py | 4 ++-- plugins/disk-cleanup/disk_cleanup.py | 4 ++-- run_agent.py | 6 +++--- tools/delegate_tool.py | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 73bed6b0670d6..3583304ecd1fe 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -25,7 +25,7 @@ import threading import time import uuid -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Callable from agent.codex_responses_adapter import _summarize_user_message_for_log from agent.display import KawaiiSpinner @@ -374,7 +374,7 @@ def run_conversation( system_message: str = None, conversation_history: List[Dict[str, Any]] = None, task_id: str = None, - stream_callback: Optional[callable] = None, + stream_callback: Optional[Callable[..., None]] = None, persist_user_message: Optional[str] = None, ) -> Dict[str, Any]: """ diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index 33910c7b40b1c..887ec17933cbf 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -118,7 +118,7 @@ def __init__( chat_id: str, config: Optional[StreamConsumerConfig] = None, metadata: Optional[dict] = None, - on_new_message: Optional[callable] = None, + on_new_message: Optional[Callable[..., None]] = None, initial_reply_to_id: Optional[str] = None, ): self.adapter = adapter diff --git a/hermes_cli/dingtalk_auth.py b/hermes_cli/dingtalk_auth.py index 50d56e845ea86..aa4110f1f933d 100644 --- a/hermes_cli/dingtalk_auth.py +++ b/hermes_cli/dingtalk_auth.py @@ -17,7 +17,7 @@ import sys import time import logging -from typing import Optional, Tuple +from typing import Optional, Tuple, Callable import requests @@ -107,7 +107,7 @@ def wait_for_registration_success( device_code: str, interval: int = 3, expires_in: int = 7200, - on_waiting: Optional[callable] = None, + on_waiting: Optional[Callable[..., None]] = None, ) -> Tuple[str, str]: """Block until the registration succeeds or times out. diff --git a/plugins/disk-cleanup/disk_cleanup.py b/plugins/disk-cleanup/disk_cleanup.py index fddb62dacb06e..7ea704de342d7 100755 --- a/plugins/disk-cleanup/disk_cleanup.py +++ b/plugins/disk-cleanup/disk_cleanup.py @@ -26,7 +26,7 @@ import shutil from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, Callable try: from hermes_constants import get_hermes_home @@ -397,7 +397,7 @@ def quick() -> Dict[str, Any]: # --------------------------------------------------------------------------- def deep( - confirm: Optional[callable] = None, + confirm: Optional[Callable[[Any], bool]] = None, ) -> Dict[str, Any]: """Deep cleanup. diff --git a/run_agent.py b/run_agent.py index 9c720bcbfe091..0a5f0412ba128 100644 --- a/run_agent.py +++ b/run_agent.py @@ -45,7 +45,7 @@ import time import threading import uuid -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any, Optional, Callable # NOTE: `from openai import OpenAI` is deliberately NOT at module top — the # SDK pulls ~240 ms of imports. We expose `OpenAI` as a thin proxy object # that imports the SDK on first call/isinstance check. This preserves: @@ -5095,14 +5095,14 @@ def run_conversation( system_message: str = None, conversation_history: List[Dict[str, Any]] = None, task_id: str = None, - stream_callback: Optional[callable] = None, + stream_callback: Optional[Callable[..., None]] = None, persist_user_message: Optional[str] = None, ) -> Dict[str, Any]: """Forwarder — see ``agent.conversation_loop.run_conversation``.""" from agent.conversation_loop import run_conversation return run_conversation(self, user_message, system_message, conversation_history, task_id, stream_callback, persist_user_message) - def chat(self, message: str, stream_callback: Optional[callable] = None) -> str: + def chat(self, message: str, stream_callback: Optional[Callable[..., None]] = None) -> str: """ Simple chat interface that returns just the final response. diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 6e195dfe59fed..d819cb15455b1 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -28,7 +28,7 @@ ThreadPoolExecutor, TimeoutError as FuturesTimeoutError, ) -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Callable from toolsets import TOOLSETS @@ -725,7 +725,7 @@ def _build_child_progress_callback( depth: Optional[int] = None, model: Optional[str] = None, toolsets: Optional[List[str]] = None, -) -> Optional[callable]: +) -> Optional[Callable[..., None]]: """Build a callback that relays child agent tool calls to the parent display. Two display paths: