NousResearch/hermes-agent

Computer-use AX tree context size and routing fixes

Cluster 22865. Data as of

Nothing open on GitHub. Fix merged in #30145 on 22 May 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #30145. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #30145. That is separate from the fix event.
Still open
Nothing open on GitHub.

These items address two computer-use issues: capping the accessibility element array to prevent context overflow on large AX trees (e.g. Electron/Obsidian), and routing SOM/vision captures through the auxiliary vision path.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #30126 fix(computer_use): route SOM/vision captures via auxiliary.vision (#24015)

    merged to main

    files: tests/tools/test_computer_use_capture_routing.py, tests/tools/test_computer_use_vision_routing.py, tools/computer_use/tool.py, tools/computer_use/vision_routing.py

    Show the diff
    • tests/tools/test_computer_use_capture_routing.py
      @@ -0,0 +1,431 @@
      +"""End-to-end regression for #24015 — capture routing via auxiliary.vision.
      +
      +When ``computer_use(action='capture', mode='som'|'vision')`` returns a
      +screenshot, ``_capture_response`` previously always returned a
      +``_multimodal`` envelope. For non-vision main models, or when the user
      +explicitly configured ``auxiliary.vision`` in ``config.yaml``, that
      +envelope tripped HTTP 404 / 400 at the provider boundary even though a
      +perfectly good vision backend was sitting in config waiting to be used.
      +
      +This file exercises the integrated ``_capture_response`` flow with
      +deterministic stubs for:
      +
      +* ``should_route_capture_to_aux_vision`` (the policy decision)
      +* ``_run_async`` (sync->async bridge)
      +* ``vision_analyze_tool`` (the aux LLM call)
      +* ``hermes_constants.get_hermes_dir`` (cache path)
      +
      +…so the full code path is covered without a live cua-driver, a real
      +auxiliary client, or network access.
      +"""
      +
      +from __future__ import annotations
      +
      +import base64
      +import json
      +import os
      +from pathlib import Path
      +from typing import Any
      +from unittest.mock import MagicMock, patch
      +
      +import pytest
      +
      +
      +# ---------------------------------------------------------------------------
      +# Fixtures / helpers
      +# ---------------------------------------------------------------------------
      +
      +# 1×1 PNG (transparent) — minimal bytes that decode cleanly.
      +_PNG_B64 = (
      +    "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42m"
      +    "NkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
      +)
      +
      +# 1×1 JPEG — used to verify mime detection works for either stream type.
      +_JPEG_B64 = (
      +    "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEB"
      +    "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/"
      +)
      +
      +
      [redacted:email]
      +def tmp_cache_dir(tmp_path):
      +    """Override get_hermes_dir so cache writes land under tmp_path."""
      +    cache_dir = tmp_path / "cache_vision"
      +    cache_dir.mkdir()
      +
      +    def _fake_get(*_args, **_kw):
      +        return cache_dir
      +
      +    with patch("hermes_constants.get_hermes_dir", _fake_get):
      +        yield cache_dir
      +
      +
      +def _make_capture(
      +    *,
      +    png_b64: str = _PNG_B64,
      +    mode: str = "som",
      +    elements=None,
      +    app: str = "Safari",
      +    window_title: str = "GitHub – Issue #24015",
      +    width: int = 1280,
      +    height: int = 800,
      +):
      +    from tools.computer_use.backend import CaptureResult, UIElement
      +
      +    elements = list(elements or [
      +        UIElement(index=0, role="AXButton", label="Sign in",
      +                  bounds=(10, 20, 80, 30)),
      +        UIElement(index=1, role="AXTextField", label="username",
      +                  bounds=(10, 60, 200, 24)),
      +    ])
      +    raw = base64.b64decode(png_b64, validate=False)
      +    return CaptureResult(
      +        mode=mode,
      +        width=width,
      +        height=height,
      +        png_b64=png_b64,
      +        elements=elements,
      +        app=app,
      +        window_title=window_title,
      +        png_bytes_len=len(raw),
      +    )
      +
      +
      +def _stub_aux_analysis(text: str):
      +    """Return a fake vision_analyze_tool coroutine result (JSON envelope)."""
      +    return json.dumps({"success": True, "analysis": text})
      +
      +
      +# ---------------------------------------------------------------------------
      +# _capture_response: routing OFF (current/native behaviour)
      +# ---------------------------------------------------------------------------
      +
      +class TestCaptureResponseDefaultPath:
      +    """When routing helper says 'native', the existing multimodal envelope wins."""
      +
      +    def test_som_capture_returns_multimodal_envelope_when_native(self):
      +        from tools.computer_use import tool as cu_tool
      +
      +        cap = _make_capture(png_b64=_PNG_B64, mode="som")
      +        with patch.object(cu_tool, "_should_route_through_aux_vision",
      +                          return_value=False):
      +            resp = cu_tool._capture_response(cap)
      +
      +        assert isinstance(resp, dict)
      +        assert resp.get("_multimodal") is True
      +        # Image part must use image/png MIME for a PNG payload.
      +        image_part = next(
      +            p for p in resp["content"] if p.get("type") == "image_url"
      +        )
      +        url = image_part["image_url"]["url"]
      +        assert url.startswith("data:image/png;base64,")
      +        assert "vision_analysis" not in resp
      +
      +    def test_jpeg_capture_returns_image_jpeg_mime_when_native(self):
      +        from tools.computer_use import tool as cu_tool
      +
      +        cap = _make_capture(png_b64=_JPEG_B64, mode="som")
      +        with patch.object(cu_tool, "_should_route_through_aux_vision",
      +                          return_value=False):
      +            resp = cu_tool._capture_response(cap)
      +
      +        url = next(p for p in resp["content"] if p.get("type") == "image_url")
      +        assert url["image_url"]["url"].startswith("data:image/jpeg;base64,")
      +
      +    def test_ax_only_capture_returns_text_regardless_of_routing(self):
      +        from tools.computer_use import tool as cu_tool
      +
      +        cap = _make_capture(mode="ax", png_b64="")
      +        # ax mode never has a PNG so neither path matters; assert pure text.
      +        with patch.object(cu_tool, "_should_route_through_aux_vision",
      +                          return_value=True) as routing:
      +            resp = cu_tool._capture_response(cap)
      +
      +        # ax never even consults the routing helper — short-circuited above
      +        # the image branch.
      +        routing.assert_not_called()
      +        assert isinstance(resp, str)
      +        body = json.loads(resp)
      +        assert body["mode"] == "ax"
      +
      +
      +# ---------------------------------------------------------------------------
      +# _capture_response: routing ON (the #24015 fix)
      +# ---------------------------------------------------------------------------
      +
      +class TestCaptureResponseRoutedToAuxVision:
      +    """When routing helper says 'aux', the PNG is pre-analysed and a text
      +    response is returned with no image_url parts at all."""
      +
      +    def test_som_capture_returns_text_with_vision_analysis(
      +        self, tmp_cache_dir,
      +    ):
      +        from tools.computer_use import tool as cu_tool
      +
      +        cap = _make_capture(mode="som")
      +
      +        captured_calls = {}
      +
      +        def _fake_run_async(coro):
      +            captured_calls["called"] = True
      +            return _stub_aux_analysis(
      +                "A Safari window showing a GitHub issue page with a 'Sign "
      +                "in' button and a 'username' text field."
      +            )
      +
      +        # vision_analyze_tool is async; force a sync MagicMock so we can
      +        # assert positional args without dealing with awaitables.
      +        fake_vat = MagicMock(return_value="<coro>")
      +
      +        with patch.object(cu_tool, "_should_route_through_aux_vision",
      +                          return_value=True), \
      +             patch("model_tools._run_async", side_effect=_fake_run_async), \
      +             patch("tools.vision_tools.vision_analyze_tool",
      +                   new_callable=lambda: fake_vat):
      +            resp = cu_tool._capture_response(cap)
      +
      +        # Must be a JSON string, NOT a multimodal envelope. This is exactly
      +        # the contract that prevents #24015's HTTP 404 from firing on the
      +        # next agent turn.
      +        assert isinstance(resp, str)
      +        body = json.loads(resp)
      +        assert body["mode"] == "som"
      +        assert body["app"] == "Safari"
      +        assert "Sign in" in body["vision_analysis"]
      +        assert body["vision_analysis_routed_via"] == "auxiliary.vision"
      +        # The original AX-only metadata (window title, element index, app)
      +        # is preserved alongside the new vision analysis so the agent loses
      +        # no context vs the multimodal path.
      +        assert body["window_title"] == "GitHub – Issue #24015"
      +        assert len(body["elements"]) == 2
      +
      +        assert captured_calls.get("called") is True
      +        # vision_analyze_tool was invoked with a path under the patched cache
      +        # and a non-empty prompt.
      +        args, _kwargs = fake_vat.call_args
      +        path_arg, prompt_arg = args[0], args[1]
      +        assert str(tmp_cache_dir) in path_arg
      +        assert "macOS application screenshot" in prompt_arg
      +        # AX summary is included so the aux model can ground its description
      +        # against the same set-of-mark index the agent will see.
      +        assert "Sign in" in prompt_arg
      +
      +    def test_temp_screenshot_file_is_cleaned_up_after_routing(
      +        self, tmp_cache_dir,
      +    ):
      +        from tools.computer_use import tool as cu_tool
      +
      +        cap = _make_capture(mode="som")
      +        # We capture the path the aux call sees so we can assert it's gone
      +        # after _capture_response returns.
      +        observed_path = {}
      +
      +        def _fake_run_async(_coro):
      +            return _stub_aux_analysis("description goes here")
      +
      +        def _fake_vat(image_path, _prompt):
      +            observed_path["path"] = image_path
      +            # File must exist while aux is being arranged.
      +            assert os.path.exists(image_path)
      +            return "<coro>"
      +
      +        fake_vat = MagicMock(side_effect=_fake_vat)
      +
      +        with patch.object(cu_tool, "_should_route_through_aux_vision",
      +                          return_value=True), \
      +             patch("model_tools._run_async", side_effect=_fake_run_async), \
      +             patch("tools.vision_tools.vision_analyze_tool",
      +                   new_callable=lambda: fake_vat):
      +            cu_tool._capture_response(cap)
      +
      +        # File must be unlinked after _capture_response returns.
      +        assert observed_path["path"]
      +        assert not os.path.exists(observed_path["path"])
      +
      +    def test_temp_file_cleaned_up_even_when_aux_call_raises(
      +        self, tmp_cache_dir,
      +    ):
      +        from tools.computer_use import tool as cu_tool
      +
      +        cap = _make_capture(mode="som")
      +        observed_path = {}
      +
      +        def _fake_vat(image_path, _prompt):
      +            observed_path["path"] = image_path
      +            return "<coro>"
      +
      +        def _fake_run_async(_coro):
      +            raise RuntimeError("aux LLM down")
      +
      +        fake_vat = MagicMock(side_effect=_fake_vat)
      +
      +        with patch.object(cu_tool, "_should_route_through_aux_vision",
      +                          return_value=True), \
      +             patch("model_tools._run_async", side_effect=_fake_run_async), \
      +             patch("tools.vision_tools.vision_analyze_tool",
      +                   new_callable=lambda: fake_vat):
      +            resp = cu_tool._capture_response(cap)
      +
      +        # Aux failure → fall back to multimodal envelope (so the user still
      +        # gets *something* useful even if vision is broken).
      +        assert isinstance(resp, dict)
      +        assert resp.get("_multimodal") is True
      +        # Temp file must still be cleaned up.
      +        assert observed_path["path"]
      +        assert not os.path.exists(observed_path["path"])
      +
      +    def test_empty_aux_analysis_falls_back_to_multimodal(self, tmp_cache_dir):
      +        from tools.computer_use import tool as cu_tool
      +
      +        cap = _make_capture(mode="som")
      +
      +        def _fake_run_async(_coro):
      +            return _stub_aux_analysis("")
      +
      +        fake_vat = MagicMock(return_value="<coro>")
      +
      +        with patch.object(cu_tool, "_should_route_through_aux_vision",
      +                          return_value=True), \
      +             patch("model_tools._run_async", side_effect=_fake_run_async), \
      +             patch("tools.vision_tools.vision_analyze_tool",
      +                   new_callable=lambda: fake_vat):
      +            resp = cu_tool._capture_response(cap)
      +
      +        # Empty analysis is treated as failure — we'd rather show pixels
      +        # than embed an empty 'vision_analysis' string into the result.
      +        assert isinstance(resp, dict)
      +        assert resp.get("_multimodal") is True
      +
      +    def test_invalid_aux_response_falls_back_to_multimodal(self, tmp_cache_dir):
      +        from tools.computer_use import tool as cu_tool
      +
      +        cap = _make_capture(mode="som")
      +
      +        def _fake_run_async(_coro):
      +            return 1234  # not a string at all
      +
      +        fake_vat = MagicMock(return_value="<coro>")
      +
      +        with patch.object(cu_tool, "_should_route_through_aux_vision",
      +                          return_value=True), \
      +             patch("model_tools._run_async", side_effect=_fake_run_async), \
      +             patch("tools.vision_tools.vision_analyze_tool",
      +                   new_callable=lambda: fake_vat):
      +            resp = cu_tool._capture_response(cap)
      +
      +        assert isinstance(resp, dict)
      +        assert resp.get("_multimodal") is True
      +
      +
      +# ---------------------------------------------------------------------------
      +# _should_route_through_aux_vision: end-to-end with real config plumbing
      +# ---------------------------------------------------------------------------
      +
      +class TestRoutingDecisionWiring:
      +    """Verify _should_route_through_aux_vision wires the right config + helper."""
      +
      +    def test_explicit_aux_vision_in_config_routes_to_aux(self):
      +        from tools.computer_use import tool as cu_tool
      +
      +        cfg = {
      +            "model": {"default": "tencent/hy3-preview", "provider": "openrouter"},
      +            "auxiliary": {
      +                "vision": {
      +                    "provider": "openrouter",
      +                    "model": "google/gemini-2.5-flash",
      +                }
      +            },
      +        }
      +        with patch("agent.auxiliary_client._read_main_provider",
      +                   return_value="openrouter"), \
      +             patch("agent.auxiliary_client._read_main_model",
      +                   return_value="tencent/hy3-preview"), \
      +             patch("hermes_cli.config.load_config", return_value=cfg):
      +            assert cu_tool._should_route_through_aux_vision() is True
      +
      +    def test_no_explicit_aux_and_vision_capable_main_keeps_multimodal(self):
      +        from tools.computer_use import tool as cu_tool
      +
      +        cfg = {
      +            "model": {"default": "claude-opus-4-5", "provider": "anthropic"},
      +        }
      +        with patch("agent.auxiliary_client._read_main_provider",
      +                   return_value="anthropic"), \
      +             patch("agent.auxiliary_client._read_main_model",
      +                   return_value="claude-opus-4-5"), \
      +             patch("hermes_cli.config.load_config", return_value=cfg), \
      +             patch("tools.computer_use.vision_routing._lookup_supports_vision",
      +                   return_value=True), \
      +             patch("tools.computer_use.vision_routing."
      +                   "_provider_accepts_multimodal_tool_result",
      +                   return_value=True):
      +            assert cu_tool._should_route_through_aux_vision() is False
      +
      +    def test_config_load_failure_disables_routing_safely(self):
      +        from tools.computer_use import tool as cu_tool
      +
      +        with patch("hermes_cli.config.load_config",
      +                   side_effect=RuntimeError("config.yaml unreadable")):
      +            # No exception should bubble up — fail open by returning False
      +            # so the legacy multimodal envelope continues to work.
      +            assert cu_tool._should_route_through_aux_vision() is False
      +
      +    def test_helper_decision_exception_is_swallowed(self):
      +        from tools.computer_use import tool as cu_tool
      +        from tools.computer_use import vision_routing as vr_mod
      +
      +        with patch("agent.auxiliary_client._read_main_provider",
      +                   return_value="openrouter"), \
      +             patch("agent.auxiliary_client._read_main_model",
      +                   return_value="x"), \
      +             patch("hermes_cli.config.load_config", return_value={}), \
      +             patch.object(vr_mod, "should_route_capture_to_aux_vision",
      +                          side_effect=ValueError("policy bug")):
      +            assert cu_tool._should_route_through_aux_vision() is False
      +
      +
      +# ---------------------------------------------------------------------------
      +# Bug reproduction marker — proves the fix is needed.
      +# ---------------------------------------------------------------------------
      +
      +class TestBugReproductionAnchor:
      +    """Without the fix, this test would assert the wrong thing.
      +
      +    On upstream/main HEAD prior to this branch, _capture_response returns a
      +    multimodal envelope unconditionally — so when a non-vision main model
      +    is configured, the captured PNG is delivered to the main provider as
      +    image_url content and the request is rejected with HTTP 404. We don't
      +    have a live provider here, but we can pin the contract: with routing
      +    enabled the response MUST be a JSON string with no image_url parts.
      +    """
      +
      +    def test_non_vision_main_model_never_returns_image_url_when_routed(
      +        self, tmp_cache_dir,
      +    ):
      +        from tools.computer_use import tool as cu_tool
      +
      +        cap = _make_capture(mode="som")
      +
      +        def _fake_run_async(_coro):
      +            return _stub_aux_analysis(
      +                "Screenshot showing a GitHub.com window with a sign-in "
      +                "form."
      +            )
      +
      +        fake_vat = MagicMock(return_value="<coro>")
      +
      +        with patch.object(cu_tool, "_should_route_through_aux_vision",
      +                          return_value=True), \
      +             patch("model_tools._run_async", side_effect=_fake_run_async), \
      +             patch("tools.vision_tools.vision_analyze_tool",
      +                   new_callable=lambda: fake_vat):
      +            resp = cu_tool._capture_response(cap)
      +
      +        # Must be a string (text-only result).
      +        assert isinstance(resp, str)
      +        # Must NOT contain a base64 image URL anywhere — that's what tripped
      +        # 'No endpoints found that support image input' on the reporter's
      +        # main provider in #24015.
      +        assert "data:image" not in resp
      +        assert "image_url" not in resp
    • tests/tools/test_computer_use_vision_routing.py
      @@ -0,0 +1,260 @@
      +"""Unit tests for tools.computer_use.vision_routing.
      +
      +Cover the small ``should_route_capture_to_aux_vision`` policy helper that
      +decides whether a captured screenshot from ``computer_use(action='capture')``
      +should be returned as a multimodal envelope (main model handles vision
      +natively) or pre-analysed via the ``auxiliary.vision`` pipeline so the
      +main model only sees text.
      +
      +The companion end-to-end regression for #24015 lives in
      +``tests/tools/test_computer_use_capture_routing.py``; this file pins the
      +unit contract of the helper in isolation so behaviour does not regress
      +silently if the surrounding ``computer_use`` plumbing is refactored.
      +"""
      +
      +from __future__ import annotations
      +
      +from unittest.mock import patch
      +
      +import pytest
      +
      +
      +# ---------------------------------------------------------------------------
      +# _explicit_aux_vision_override
      +# ---------------------------------------------------------------------------
      +
      +class TestExplicitAuxVisionOverride:
      +    """Mirror agent.image_routing — config detection must agree across paths."""
      +
      +    def test_returns_false_for_none_cfg(self):
      +        from tools.computer_use.vision_routing import _explicit_aux_vision_override
      +        assert _explicit_aux_vision_override(None) is False
      +
      +    def test_returns_false_for_non_dict_cfg(self):
      +        from tools.computer_use.vision_routing import _explicit_aux_vision_override
      +        assert _explicit_aux_vision_override("not-a-dict") is False
      +        assert _explicit_aux_vision_override([]) is False
      +
      +    def test_returns_false_when_auxiliary_block_missing(self):
      +        from tools.computer_use.vision_routing import _explicit_aux_vision_override
      +        assert _explicit_aux_vision_override({}) is False
      +        assert _explicit_aux_vision_override({"model": {"default": "x"}}) is False
      +
      +    def test_returns_false_when_vision_block_missing(self):
      +        from tools.computer_use.vision_routing import _explicit_aux_vision_override
      +        cfg = {"auxiliary": {"compression": {"provider": "openai"}}}
      +        assert _explicit_aux_vision_override(cfg) is False
      +
      +    def test_returns_false_for_blank_provider_no_model_no_base_url(self):
      +        from tools.computer_use.vision_routing import _explicit_aux_vision_override
      +        cfg = {"auxiliary": {"vision": {"provider": "", "model": "", "base_url": ""}}}
      +        assert _explicit_aux_vision_override(cfg) is False
      +
      +    def test_returns_false_for_provider_auto(self):
      +        from tools.computer_use.vision_routing import _explicit_aux_vision_override
      +        cfg = {"auxiliary": {"vision": {"provider": "auto"}}}
      +        assert _explicit_aux_vision_override(cfg) is False
      +
      +    def test_returns_false_for_provider_AUTO_uppercase(self):
      +        from tools.computer_use.vision_routing import _explicit_aux_vision_override
      +        cfg = {"auxiliary": {"vision": {"provider": "  AUTO  "}}}
      +        assert _explicit_aux_vision_override(cfg) is False
      +
      +    def test_returns_true_for_explicit_provider(self):
      +        from tools.computer_use.vision_routing import _explicit_aux_vision_override
      +        cfg = {"auxiliary": {"vision": {"provider": "openrouter"}}}
      +        assert _explicit_aux_vision_override(cfg) is True
      +
      +    def test_returns_true_for_explicit_model_only(self):
      +        from tools.computer_use.vision_routing import _explicit_aux_vision_override
      +        cfg = {"auxiliary": {"vision": {"model": "google/gemini-2.5-flash"}}}
      +        assert _explicit_aux_vision_override(cfg) is True
      +
      +    def test_returns_true_for_explicit_base_url_only(self):
      +        from tools.computer_use.vision_routing import _explicit_aux_vision_override
      +        cfg = {"auxiliary": {"vision": {"base_url": "http://localhost:1234/v1"}}}
      +        assert _explicit_aux_vision_override(cfg) is True
      +
      +    def test_returns_true_for_provider_auto_plus_explicit_model(self):
      +        """``provider: auto`` + an explicit model still counts as override."""
      +        from tools.computer_use.vision_routing import _explicit_aux_vision_override
      +        cfg = {
      +            "auxiliary": {
      +                "vision": {"provider": "auto", "model": "claude-3-haiku"},
      +            }
      +        }
      +        assert _explicit_aux_vision_override(cfg) is True
      +
      +    def test_handles_non_dict_vision_block(self):
      +        from tools.computer_use.vision_routing import _explicit_aux_vision_override
      +        cfg = {"auxiliary": {"vision": "not-a-dict"}}
      +        assert _explicit_aux_vision_override(cfg) is False
      +
      +
      +# ---------------------------------------------------------------------------
      +# should_route_capture_to_aux_vision
      +# ---------------------------------------------------------------------------
      +
      +class TestRouteDecision:
      +    """End-to-end policy: explicit override > tool-result support > vision caps."""
      +
      +    def test_explicit_override_routes_to_aux_even_for_vision_main(self):
      +        """Issue #24015 core repro: explicit aux config must win.
      +
      +        Even if the main model fully supports vision (Anthropic / Claude),
      +        an explicit ``auxiliary.vision`` block means the user wants their
      +        configured backend used. Don't silently bypass it.
      +        """
      +        from tools.computer_use import vision_routing
      +
      +        cfg = {
      +            "auxiliary": {
      +                "vision": {
      +                    "provider": "openrouter",
      +                    "model": "google/gemini-2.5-flash",
      +                }
      +            }
      +        }
      +        with patch.object(vision_routing, "_lookup_supports_vision", return_value=True), \
      +             patch.object(vision_routing,
      +                          "_provider_accepts_multimodal_tool_result",
      +                          return_value=True):
      +            assert vision_routing.should_route_capture_to_aux_vision(
      +                "anthropic", "claude-opus-4-5", cfg
      +            ) is True
      +
      +    def test_non_vision_main_model_routes_to_aux(self):
      +        """The reported #24015 scenario: tencent/hy3-preview has no vision."""
      +        from tools.computer_use import vision_routing
      +
      +        cfg = {"model": {"default": "tencent/hy3-preview", "provider": "openrouter"}}
      +        with patch.object(vision_routing, "_lookup_supports_vision", return_value=False), \
      +             patch.object(vision_routing,
      +                          "_provider_accepts_multimodal_tool_result",
      +                          return_value=True):
      +            assert vision_routing.should_route_capture_to_aux_vision(
      +                "openrouter", "tencent/hy3-preview", cfg
      +            ) is True
      +
      +    def test_vision_main_model_no_override_keeps_multimodal(self):
      +        """Default path: vision-capable main model + no aux override → native."""
      +        from tools.computer_use import vision_routing
      +
      +        with patch.object(vision_routing, "_lookup_supports_vision", return_value=True), \
      +             patch.object(vision_routing,
      +                          "_provider_accepts_multimodal_tool_result",
      +                          return_value=True):
      +            assert vision_routing.should_route_capture_to_aux_vision(
      +                "anthropic", "claude-opus-4-5", None
      +            ) is False
      +
      +    def test_provider_rejects_multimodal_tool_results_routes_to_aux(self):
      +        """Some providers' tool-result messages won't carry images at all."""
      +        from tools.computer_use import vision_routing
      +
      +        with patch.object(vision_routing, "_lookup_supports_vision", return_value=True), \
      +             patch.object(vision_routing,
      +                          "_provider_accepts_multimodal_tool_result",
      +                          return_value=False):
      +            assert vision_routing.should_route_capture_to_aux_vision(
      +                "some-aggregator", "some-vision-model", {}
      +            ) is True
      +
      +    def test_unknown_provider_capabilities_fail_closed(self):
      +        """When tool-result lookup returns None, route to aux (safe default)."""
      +        from tools.computer_use import vision_routing
      +
      +        with patch.object(vision_routing, "_lookup_supports_vision", return_value=True), \
      +             patch.object(vision_routing,
      +                          "_provider_accepts_multimodal_tool_result",
      +                          return_value=None):
      +            assert vision_routing.should_route_capture_to_aux_vision(
      +                "exotic-provider", "exotic-model", {}
      +            ) is True
      +
      +    def test_unknown_vision_capability_fails_closed(self):
      +        """When models.dev has no entry, prefer aux over a likely 404."""
      +        from tools.computer_use import vision_routing
      +
      +        with patch.object(vision_routing, "_lookup_supports_vision", return_value=None), \
      +             patch.object(vision_routing,
      +                          "_provider_accepts_multimodal_tool_result",
      +                          return_value=True):
      +            assert vision_routing.should_route_capture_to_aux_vision(
      +                "openrouter", "novel/never-seen-model", {}
      +            ) is True
      +
      +    def test_explicit_override_wins_over_unknown_caps(self):
      +        """Explicit aux config wins regardless of unknown caps elsewhere."""
      +        from tools.computer_use import vision_routing
      +
      +        cfg = {"auxiliary": {"vision": {"provider": "openrouter"}}}
      +        with patch.object(vision_routing, "_lookup_supports_vision", return_value=None), \
      +             patch.object(vision_routing,
      +                          "_provider_accepts_multimodal_tool_result",
      +                          return_value=None):
      +            assert vision_routing.should_route_capture_to_aux_vision(
      +                "openrouter", "tencent/hy3-preview", cfg
      +            ) is True
      +
      +
      +# ---------------------------------------------------------------------------
      +# Internal lookups — defensive paths
      +# ---------------------------------------------------------------------------
      +
      +class TestLookupHelpers:
      +    def test_lookup_supports_vision_returns_none_for_blank_provider(self):
      +        from tools.computer_use.vision_routing import _lookup_supports_vision
      +        assert _lookup_supports_vision("", "claude") is None
      +
      +    def test_lookup_supports_vision_returns_none_for_blank_model(self):
      +        from tools.computer_use.vision_routing import _lookup_supports_vision
      +        assert _lookup_supports_vision("anthropic", "") is None
      +
      +    def test_lookup_supports_vision_handles_lookup_exception(self):
      +        """Underlying caps lookup may raise; helper must swallow + return None."""
      +        from tools.computer_use import vision_routing
      +
      +        def _boom(_provider, _model):
      +            raise RuntimeError("models.dev unreachable")
      +
      +        with patch("agent.models_dev.get_model_capabilities", side_effect=_boom):
      +            assert vision_routing._lookup_supports_vision("anthropic", "claude") is None
      +
      +    def test_lookup_supports_vision_returns_none_when_caps_missing(self):
      +        from tools.computer_use import vision_routing
      +
      +        with patch("agent.models_dev.get_model_capabilities", return_value=None):
      +            assert vision_routing._lookup_supports_vision("anthropic", "claude") is None
      +
      +    def test_provider_accepts_multimodal_tool_result_returns_none_for_blank_provider(self):
      +        from tools.computer_use.vision_routing import (
      +            _provider_accepts_multimodal_tool_result,
      +        )
      +        assert _provider_accepts_multimodal_tool_result("", "claude") is None
      +
      +
      +# ---------------------------------------------------------------------------
      +# Module surface
      +# ---------------------------------------------------------------------------
      +
      +class TestModuleSurface:
      +    """Pin the public surface so dependents stay in lockstep."""
      +
      +    def test_should_route_capture_to_aux_vision_is_exported(self):
      +        from tools.computer_use import vision_routing
      +
      +        assert "should_route_capture_to_aux_vision" in vision_routing.__all__
      +        assert callable(vision_routing.should_route_capture_to_aux_vision)
      +
      +    @pytest.mark.parametrize("name", [
      +        "_explicit_aux_vision_override",
      +        "_lookup_supports_vision",
      +        "_provider_accepts_multimodal_tool_result",
      +    ])
      +    def test_internal_helpers_are_addressable(self, name):
      +        """Internal helpers stay importable so tests can monkeypatch them."""
      +        from tools.computer_use import vision_routing
      +
      +        assert hasattr(vision_routing, name)
      +        assert callable(getattr(vision_routing, name))
    • tools/computer_use/tool.py
      @@ -429,6 +429,21 @@ def _capture_response(cap: CaptureResult) -> Any:
           summary = "\n".join(summary_lines)
       
           if cap.png_b64 and cap.mode != "ax":
      +        # Decide whether to hand the screenshot to the auxiliary.vision
      +        # pipeline (text-only result) or keep the multimodal envelope (main
      +        # model handles vision natively). Issue #24015: previously the
      +        # multimodal envelope was returned unconditionally, so non-vision
      +        # main models tripped HTTP 404 / 400 at the provider boundary even
      +        # when auxiliary.vision was explicitly configured to handle this.
      +        if _should_route_through_aux_vision():
      +            routed = _route_capture_through_aux_vision(cap, summary)
      +            if routed is not None:
      +                return routed
      +            # Aux routing was requested but failed (no vision client, aux
      +            # call raised, etc.). Fall through to the multimodal envelope —
      +            # better to surface a tool-result error from the main model
      +            # than to silently drop the screenshot entirely.
      +
               # Detect actual image format from base64 magic bytes so the MIME type
               # matches what the data contains (cua-driver may return JPEG or PNG).
               # JPEG: base64 starts with /9j/   PNG: starts with iVBOR
      @@ -457,6 +472,140 @@ def _capture_response(cap: CaptureResult) -> Any:
           })
       
       
      +# ---------------------------------------------------------------------------
      +# auxiliary.vision routing for captured screenshots (#24015)
      +# ---------------------------------------------------------------------------
      +
      +def _should_route_through_aux_vision() -> bool:
      +    """Return True when ``_capture_response`` should hand the PNG to aux vision.
      +
      +    Reads the active main provider/model and the loaded config and asks the
      +    routing helper. Any failure (config import, runtime override missing,
      +    etc.) returns False so the existing multimodal envelope continues to be
      +    returned — fail open on the routing decision so a broken config can
      +    never silently drop the screenshot for vision-capable main models.
      +    """
      +    try:
      +        from agent.auxiliary_client import _read_main_model, _read_main_provider
      +        from hermes_cli.config import load_config
      +        from tools.computer_use.vision_routing import (
      +            should_route_capture_to_aux_vision,
      +        )
      +    except Exception as exc:  # pragma: no cover - defensive
      +        logger.debug("computer_use: aux-vision routing import failed: %s", exc)
      +        return False
      +    try:
      +        provider = _read_main_provider()
      +        model = _read_main_model()
      +        cfg = load_config()
      +    except Exception as exc:  # pragma: no cover - defensive
      +        logger.debug("computer_use: aux-vision routing config read failed: %s", exc)
      +        return False
      +    try:
      +        return bool(should_route_capture_to_aux_vision(provider, model, cfg))
      +    except Exception as exc:  # pragma: no cover - defensive
      +        logger.debug("computer_use: aux-vision routing decision failed: %s", exc)
      +        return False
      +
      +
      +def _route_capture_through_aux_vision(
      +    cap: CaptureResult,
      +    summary: str,
      +) -> Optional[str]:
      +    """Pre-analyse the captured PNG via ``vision_analyze`` and return a text result.
      +
      +    The captured base64 PNG is materialised to ``$HERMES_HOME/cache/vision/``
      +    and handed to ``vision_analyze_tool`` with a generic describe prompt.
      +    The resulting text description is merged into the existing AX/SOM
      +    summary so the main model receives a single text payload that mentions
      +    every interactable element AND a description of what the screenshot
      +    looked like.
      +
      +    Returns:
      +      A JSON-encoded text response on success.
      +      ``None`` on failure (caller falls back to the multimodal envelope).
      +    """
      +    if not cap.png_b64:
      +        return None
      +    try:
      +        import base64 as _base64
      +        import os as _os
      +        import uuid as _uuid
      +
      +        from hermes_constants import get_hermes_dir
      +        from model_tools import _run_async
      +        from tools.vision_tools import vision_analyze_tool
      +    except Exception as exc:  # pragma: no cover - defensive
      +        logger.debug("computer_use: aux-vision import failed: %s", exc)
      +        return None
      +
      +    temp_image_path = None
      +    try:
      +        try:
      +            raw = _base64.b64decode(cap.png_b64, validate=False)
      +        except Exception as exc:
      +            logger.debug("computer_use: failed to decode capture base64: %s", exc)
      +            return None
      +
      +        # Pick an extension that matches the on-disk bytes so vision_analyze's
      +        # MIME sniffing returns the right content-type.
      +        ext = ".jpg" if cap.png_b64[:8].startswith("/9j/") else ".png"
      +        cache_dir = get_hermes_dir("cache/vision", "temp_vision_images")
      +        temp_image_path = cache_dir / f"computer_use_{_uuid.uuid4().hex}{ext}"
      +        temp_image_path.write_bytes(raw)
      +
      +        prompt = (
      +            "Describe what is visible in this macOS application screenshot in "
      +            "concise but specific terms. Mention the app name and window "
      +            "title if visible, the overall layout, any labelled buttons, "
      +            "menus or text fields, and any prominent text content the user "
      +            "would need to know about. Do not invent details that are not "
      +            "actually visible.\n\n"
      +            f"AX/SOM index for cross-reference:\n{summary}"
      +        )
      +
      +        result_json = _run_async(
      +            vision_analyze_tool(str(temp_image_path), prompt)
      +        )
      +    except Exception as exc:
      +        logger.warning(
      +            "computer_use: auxiliary.vision pre-analysis failed (%s); "
      +            "falling back to native multimodal envelope",
      +            exc,
      +        )
      +        return None
      +    finally:
      +        if temp_image_path is not None:
      +            try:
      +                _os.unlink(str(temp_image_path))
      +            except Exception:
      +                pass
      +
      +    analysis_text = ""
      +    if isinstance(result_json, str):
      +        try:
      +            parsed = json.loads(result_json)
      +            if isinstance(parsed, dict):
      +                analysis_text = str(parsed.get("analysis") or "").strip()
      +        except (TypeError, json.JSONDecodeError):
      +            analysis_text = result_json.strip()
      +
      +    if not analysis_text:
      +        return None
      +
      +    return json.dumps({
      +        "mode": cap.mode,
      +        "width": cap.width,
      +        "height": cap.height,
      +        "app": cap.app,
      +        "window_title": cap.window_title,
      +        "elements": [_element_to_dict(e) for e in cap.elements],
      +        "summary": summary,
      +        "vision_analysis": analysis_text,
      +        "vision_analysis_routed_via": "auxiliary.vision",
      +    })
      +
      +
       def _maybe_follow_capture(
           backend: ComputerUseBackend, res: ActionResult, do_capture: bool,
       ) -> Any:
    • tools/computer_use/vision_routing.py
      @@ -0,0 +1,152 @@
      +"""Vision-routing decisions for ``computer_use`` capture results.
      +
      +Background
      +----------
      +``computer_use(action='capture', mode='som'|'vision')`` returns a
      +``_multimodal`` envelope containing the captured screenshot. That envelope
      +is delivered back to the **active session model** as the tool result. When
      +the active main model has no vision capability (e.g. text-only or
      +text+code-only models), or when the active provider rejects multimodal
      +content inside tool-result messages, the screenshot trips a 404 / 400 at
      +the provider boundary and the agent loop reports a hard tool failure.
      +
      +Issue #24015 reports this regression for the ``cua-driver`` backend:
      +configuring ``auxiliary.vision`` (a dedicated vision-capable model) in
      +``config.yaml`` was silently ignored — the screenshot was still routed at
      +the *main* model and failed with HTTP 404 ``No endpoints found that
      +support image input`` even though a perfectly good vision backend was
      +sitting in config waiting to be used.
      +
      +This module centralises the small policy decision: should a captured
      +screenshot be returned as multimodal content (main model handles vision
      +natively) or pre-analysed via the auxiliary vision pipeline so the main
      +model only ever sees text?
      +
      +Behaviour (mirrors ``vision_analyze`` for consistency)
      +------------------------------------------------------
      +* If the user explicitly configured ``auxiliary.vision`` (any of
      +  ``provider``, ``model``, or ``base_url`` non-empty / not ``"auto"``),
      +  the screenshot is routed through the aux vision pipeline. Users who
      +  pay for a dedicated vision model usually want it used.
      +* Otherwise, if the active main model+provider can carry an image inside
      +  a tool-result message AND the model reports ``supports_vision=True``
      +  in models.dev metadata, return ``False`` (use the multimodal path).
      +* In every other case (non-vision main model, provider that does not
      +  accept multimodal tool results, lookup failure), route through aux
      +  vision so the main model receives a text description it can act on.
      +
      +The decision intentionally fails *closed* (i.e. towards aux routing) when
      +metadata is missing or ambiguous: returning a screenshot to a model that
      +cannot read it is a hard tool failure, while routing it through aux costs
      +one extra LLM call and yields a usable description.
      +"""
      +
      +from __future__ import annotations
      +
      +import logging
      +from typing import Any, Dict, Optional
      +
      +logger = logging.getLogger(__name__)
      +
      +
      +def _explicit_aux_vision_override(cfg: Optional[Dict[str, Any]]) -> bool:
      +    """True when ``auxiliary.vision`` carries a non-default user override.
      +
      +    Mirrors ``agent.image_routing._explicit_aux_vision_override`` so the
      +    capture path and the user-attached-image path agree on what counts as
      +    an explicit user request for the aux vision pipeline. ``provider:
      +    "auto"``, blank values, or a missing block all count as *not*
      +    explicit.
      +    """
      +    if not isinstance(cfg, dict):
      +        return False
      +    aux = cfg.get("auxiliary") or {}
      +    if not isinstance(aux, dict):
      +        return False
      +    vision = aux.get("vision") or {}
      +    if not isinstance(vision, dict):
      +        return False
      +
      +    provider = str(vision.get("provider") or "").strip().lower()
      +    model = str(vision.get("model") or "").strip()
      +    base_url = str(vision.get("base_url") or "").strip()
      +
      +    if provider in ("", "auto") and not model and not base_url:
      +        return False
      +    return True
      +
      +
      +def _lookup_supports_vision(provider: str, model: str) -> Optional[bool]:
      +    """Return models.dev ``supports_vision`` for *(provider, model)* or None."""
      +    if not provider or not model:
      +        return None
      +    try:
      +        from agent.models_dev import get_model_capabilities
      +        caps = get_model_capabilities(provider, model)
      +    except Exception as exc:  # pragma: no cover - defensive
      +        logger.debug(
      +            "computer_use vision_routing: caps lookup failed for %s:%s — %s",
      +            provider, model, exc,
      +        )
      +        return None
      +    if caps is None:
      +        return None
      +    return bool(getattr(caps, "supports_vision", False))
      +
      +
      +def _provider_accepts_multimodal_tool_result(provider: str, model: str) -> Optional[bool]:
      +    """Return whether *provider*+*model* carries images inside tool-result messages.
      +
      +    Reuses ``tools.vision_tools._supports_media_in_tool_results`` so the
      +    capture-routing decision stays in lockstep with the
      +    ``vision_analyze`` native fast path. Returns None on import failure
      +    so callers fall back to aux routing rather than guessing.
      +    """
      +    if not provider:
      +        return None
      +    try:
      +        from tools.vision_tools import _supports_media_in_tool_results
      +    except Exception as exc:  # pragma: no cover - defensive
      +        logger.debug(
      +            "computer_use vision_routing: tool-result support lookup failed: %s",
      +            exc,
      +        )
      +        return None
      +    return bool(_supports_media_in_tool_results(provider, model))
      +
      +
      +def should_route_capture_to_aux_vision(
      +    provider: str,
      +    model: str,
      +    cfg: Optional[Dict[str, Any]],
      +) -> bool:
      +    """Return True iff the captured screenshot should be pre-analysed via aux vision.
      +
      +    Args:
      +      provider: active inference provider id (e.g. ``"openrouter"``,
      +        ``"anthropic"``, ``"openai-codex"``). Lower-case canonical id.
      +      model:    active main model slug as it would be sent to the provider.
      +      cfg:      loaded ``config.yaml`` dict (or None).
      +
      +    Returns:
      +      ``True`` when the caller should hand the screenshot to the aux vision
      +      pipeline (and surface a text-only tool result). ``False`` when the
      +      caller should keep the existing multimodal envelope (main model
      +      handles vision natively).
      +    """
      +    if _explicit_aux_vision_override(cfg):
      +        return True
      +
      +    accepts_tool_image = _provider_accepts_multimodal_tool_result(provider, model)
      +    if accepts_tool_image is None or accepts_tool_image is False:
      +        return True
      +
      +    supports_vision = _lookup_supports_vision(provider, model)
      +    if supports_vision is True:
      +        return False
      +    return True
      +
      +
      +__all__ = [
      +    "should_route_capture_to_aux_vision",
      +]
  • #30145 fix(computer-use): cap AX elements array to prevent context blowup (#22865)

    merged to main

    files: tests/tools/test_computer_use.py, tools/computer_use/schema.py, tools/computer_use/tool.py

    Show the diff
    • tests/tools/test_computer_use.py
      @@ -76,6 +76,27 @@ def test_capture_mode_enum_has_som_vision_ax(self):
               modes = set(COMPUTER_USE_SCHEMA["parameters"]["properties"]["mode"]["enum"])
               assert modes == {"som", "vision", "ax"}
       
      +    def test_schema_exposes_max_elements_cap_for_capture(self):
      +        from tools.computer_use.schema import COMPUTER_USE_SCHEMA
      +        props = COMPUTER_USE_SCHEMA["parameters"]["properties"]
      +        assert "max_elements" in props
      +        assert props["max_elements"]["type"] == "integer"
      +        assert props["max_elements"].get("minimum", 1) >= 1
      +
      +    def test_schema_max_elements_documents_default_and_upper_bound(self):
      +        """Schema description must agree with the runtime. The original PR
      +        text said "Default 100" without a corresponding `default` field, and
      +        had no upper bound — both Copilot findings.
      +        """
      +        from tools.computer_use.schema import COMPUTER_USE_SCHEMA
      +        from tools.computer_use.tool import (
      +            _DEFAULT_MAX_ELEMENTS,
      +            _MAX_ALLOWED_MAX_ELEMENTS,
      +        )
      +        prop = COMPUTER_USE_SCHEMA["parameters"]["properties"]["max_elements"]
      +        assert prop.get("default") == _DEFAULT_MAX_ELEMENTS
      +        assert prop.get("maximum") == _MAX_ALLOWED_MAX_ELEMENTS
      +
       
       class TestRegistration:
           def test_tool_registers_with_registry(self):
      @@ -337,6 +358,193 @@ def focus_app(self, app, raise_window=False): ...
               assert "AXButton" in text_part["text"]
               assert "AXTextField" in text_part["text"]
       
      +    def _ax_backend_with(self, count: int):
      +        """Construct a fake backend that yields ``count`` AX elements."""
      +        from tools.computer_use.backend import CaptureResult, UIElement
      +
      +        elements = [
      +            UIElement(index=i + 1, role="AXButton", label=f"el-{i}", bounds=(0, 0, 1, 1))
      +            for i in range(count)
      +        ]
      +
      +        class FakeBackend:
      +            def start(self): pass
      +            def stop(self): pass
      +            def is_available(self): return True
      +            def capture(self, mode="som", app=None):
      +                return CaptureResult(
      +                    mode=mode, width=800, height=600,
      +                    png_b64="",
      +                    elements=list(elements),
      +                    app="Obsidian",
      +                )
      +            def click(self, **kw): ...
      +            def drag(self, **kw): ...
      +            def scroll(self, **kw): ...
      +            def type_text(self, text): ...
      +            def key(self, keys): ...
      +            def list_apps(self): return []
      +            def focus_app(self, app, raise_window=False): ...
      +
      +        return FakeBackend()
      +
      +    def test_capture_ax_caps_elements_at_default_for_dense_trees(self):
      +        """Regression for #22865: an Electron-style 600-element AX tree must
      +        not emit the entire array verbatim into the tool result.
      +        """
      +        from tools.computer_use import tool as cu_tool
      +
      +        fake_backend = self._ax_backend_with(600)
      +        cu_tool.reset_backend_for_tests()
      +        with patch.object(cu_tool, "_get_backend", return_value=fake_backend):
      +            out = cu_tool.handle_computer_use({"action": "capture", "mode": "ax"})
      +
      +        parsed = json.loads(out)
      +        assert parsed["mode"] == "ax"
      +        assert parsed["total_elements"] == 600
      +        assert len(parsed["elements"]) == cu_tool._DEFAULT_MAX_ELEMENTS
      +        assert parsed["truncated_elements"] == 600 - cu_tool._DEFAULT_MAX_ELEMENTS
      +        # Truncation must be visible in the human summary so the model knows
      +        # the JSON view is partial and can re-issue with a tighter scope.
      +        assert "truncated to" in parsed["summary"]
      +
      +    def test_capture_ax_honors_explicit_max_elements_override(self):
      +        from tools.computer_use import tool as cu_tool
      +
      +        fake_backend = self._ax_backend_with(600)
      +        cu_tool.reset_backend_for_tests()
      +        with patch.object(cu_tool, "_get_backend", return_value=fake_backend):
      +            out = cu_tool.handle_computer_use(
      +                {"action": "capture", "mode": "ax", "max_elements": 250}
      +            )
      +
      +        parsed = json.loads(out)
      +        assert len(parsed["elements"]) == 250
      +        assert parsed["truncated_elements"] == 350
      +
      +    def test_capture_ax_below_cap_is_unchanged(self):
      +        """Backwards-compat: small captures keep the full elements array and
      +        do not surface a `truncated_elements` field.
      +        """
      +        from tools.computer_use import tool as cu_tool
      +
      +        fake_backend = self._ax_backend_with(5)
      +        cu_tool.reset_backend_for_tests()
      +        with patch.object(cu_tool, "_get_backend", return_value=fake_backend):
      +            out = cu_tool.handle_computer_use({"action": "capture", "mode": "ax"})
      +
      +        parsed = json.loads(out)
      +        assert len(parsed["elements"]) == 5
      +        assert parsed["total_elements"] == 5
      +        assert "truncated_elements" not in parsed
      +        assert "truncated to" not in parsed["summary"]
      +
      +    def test_capture_ax_invalid_max_elements_falls_back_to_default(self):
      +        """Malformed `max_elements` (string, negative, zero) must not silently
      +        disable the cap and re-introduce the original unbounded behavior.
      +        """
      +        from tools.computer_use import tool as cu_tool
      +
      +        fake_backend = self._ax_backend_with(600)
      +        cu_tool.reset_backend_for_tests()
      +        for bad in ("not-a-number", 0, -10):
      +            with patch.object(cu_tool, "_get_backend", return_value=fake_backend):
      +                out = cu_tool.handle_computer_use(
      +                    {"action": "capture", "mode": "ax", "max_elements": bad}
      +                )
      +            parsed = json.loads(out)
      +            assert len(parsed["elements"]) == cu_tool._DEFAULT_MAX_ELEMENTS, (
      +                f"bad max_elements={bad!r} disabled the cap"
      +            )
      +
      +    def test_capture_ax_clamps_oversized_max_elements_to_hard_cap(self):
      +        """A caller passing a very large `max_elements` must not be able to
      +        disable the safeguard. The cap is clamped to a hard upper bound so
      +        the context-blow-up protection cannot be bypassed by argument.
      +        """
      +        from tools.computer_use import tool as cu_tool
      +
      +        fake_backend = self._ax_backend_with(5000)
      +        cu_tool.reset_backend_for_tests()
      +        with patch.object(cu_tool, "_get_backend", return_value=fake_backend):
      +            out = cu_tool.handle_computer_use(
      +                {"action": "capture", "mode": "ax", "max_elements": 10_000}
      +            )
      +        parsed = json.loads(out)
      +        assert len(parsed["elements"]) == cu_tool._MAX_ALLOWED_MAX_ELEMENTS
      +        assert parsed["total_elements"] == 5000
      +        assert parsed["truncated_elements"] == 5000 - cu_tool._MAX_ALLOWED_MAX_ELEMENTS
      +
      +    def test_capture_ax_summary_indices_match_returned_elements(self):
      +        """When `max_elements` is below the human-summary's own line cap, the
      +        summary must not index elements that aren't in the returned array.
      +        Otherwise the model sees `#15` in the summary and finds no matching
      +        entry in `elements`.
      +        """
      +        from tools.computer_use import tool as cu_tool
      +
      +        fake_backend = self._ax_backend_with(600)
      +        cu_tool.reset_backend_for_tests()
      +        with patch.object(cu_tool, "_get_backend", return_value=fake_backend):
      +            out = cu_tool.handle_computer_use(
      +                {"action": "capture", "mode": "ax", "max_elements": 5}
      +            )
      +        parsed = json.loads(out)
      +        returned_indices = {e["index"] for e in parsed["elements"]}
      +        summary_lines = parsed["summary"].splitlines()
      +        indexed_lines = [ln for ln in summary_lines if ln.lstrip().startswith("#")]
      +        for ln in indexed_lines:
      +            idx_token = [redacted:secret-assignment]"#")
      +            idx = int(idx_token)
      +            assert idx in returned_indices, (
      +                f"summary references #{idx} but it is absent from elements payload "
      +                f"(returned: {sorted(returned_indices)})"
      +            )
      +
      +    def test_capture_multimodal_summary_omits_truncation_note(self):
      +        """The som/vision multimodal envelope returns a screenshot, not an
      +        `elements` array — so a "response truncated to N of M elements"
      +        claim in the summary would be inaccurate.
      +        """
      +        from tools.computer_use.backend import CaptureResult, UIElement
      +        from tools.computer_use import tool as cu_tool
      +
      +        fake_png = "iVBORw0KGgo="
      +        elements = [
      +            UIElement(index=i + 1, role="AXButton", label=f"el-{i}", bounds=(0, 0, 1, 1))
      +            for i in range(600)
      +        ]
      +
      +        class FakeBackend:
      +            def start(self): pass
      +            def stop(self): pass
      +            def is_available(self): return True
      +            def capture(self, mode="som", app=None):
      +                return CaptureResult(
      +                    mode=mode, width=800, height=600,
      +                    png_b64=fake_png, elements=list(elements),
      +                    app="Obsidian",
      +                )
      +            def click(self, **kw): ...
      +            def drag(self, **kw): ...
      +            def scroll(self, **kw): ...
      +            def type_text(self, text): ...
      +            def key(self, keys): ...
      +            def list_apps(self): return []
      +            def focus_app(self, app, raise_window=False): ...
      +
      +        cu_tool.reset_backend_for_tests()
      +        with patch.object(cu_tool, "_get_backend", return_value=FakeBackend()):
      +            out = cu_tool.handle_computer_use({"action": "capture", "mode": "som"})
      +
      +        assert isinstance(out, dict) and out["_multimodal"] is True
      +        text_part = next(p for p in out["content"] if p.get("type") == "text")
      +        assert "truncated to" not in text_part["text"], (
      +            "multimodal response carries an image, not an elements array; "
      +            "the truncation note describes a payload field that isn't present"
      +        )
      +        assert "truncated to" not in out["text_summary"]
      +
       
       # ---------------------------------------------------------------------------
       # Anthropic adapter: multimodal tool-result conversion
    • tools/computer_use/schema.py
      @@ -75,6 +75,28 @@
                           "frontmost app's window or the whole screen."
                       ),
                   },
      +            "max_elements": {
      +                "type": "integer",
      +                "description": (
      +                    "Optional cap on the AX `elements` array returned by "
      +                    "`action='capture'`. Default 100, hard maximum 1000. "
      +                    "Dense UIs (Electron apps such as Obsidian or VS Code, "
      +                    "JetBrains IDEs) can publish 500+ AX nodes — capping "
      +                    "prevents a single capture from blowing session "
      +                    "context. When the cap trims the response, "
      +                    "`total_elements` and `truncated_elements` are "
      +                    "surfaced in the result so you can re-call with "
      +                    "`app=` to narrow scope or raise `max_elements` when "
      +                    "the full tree is required. Has no effect on "
      +                    "`mode='som'` / `mode='vision'` when a screenshot is "
      +                    "included in the response; only the rare image-"
      +                    "missing fallback returns an `elements` array and is "
      +                    "subject to the cap."
      +                ),
      +                "default": 100,
      +                "minimum": 1,
      +                "maximum": 1000,
      +            },
                   # ── click / drag / scroll targeting ────────────────────
                   "element": {
                       "type": "integer",
    • tools/computer_use/tool.py
      @@ -317,7 +317,7 @@ def _dispatch(backend: ComputerUseBackend, action: str, args: Dict[str, Any]) ->
               if mode not in {"som", "vision", "ax"}:
                   return json.dumps({"error": f"bad mode {mode!r}; use som|vision|ax"})
               cap = backend.capture(mode=mode, app=args.get("app"))
      -        return _capture_response(cap)
      +        return _capture_response(cap, max_elements=_coerce_max_elements(args.get("max_elements")))
       
           if action == "wait":
               seconds = float(args.get("seconds", 1.0))
      @@ -416,16 +416,62 @@ def _text_response(res: ActionResult) -> str:
           return json.dumps(payload)
       
       
      -def _capture_response(cap: CaptureResult) -> Any:
      -    element_index = _format_elements(cap.elements)
      +# Default cap for the AX `elements` array returned by capture. Dense UIs
      +# (Electron apps, Obsidian, JetBrains IDEs) can publish 500+ AX nodes, which
      +# can exhaust session context after a single capture. The model-facing
      +# `max_elements` argument lets callers raise this when they need the full tree.
      +_DEFAULT_MAX_ELEMENTS = 100
      +# Hard upper bound on caller-supplied `max_elements`. Without this, a tool
      +# call passing a very large integer would silently disable the safeguard and
      +# reintroduce the original unbounded behavior.
      +_MAX_ALLOWED_MAX_ELEMENTS = 1000
      +
      +
      +def _coerce_max_elements(value: Any) -> int:
      +    """Validate the caller-supplied ``max_elements``.
      +
      +    Falls back to :data:`_DEFAULT_MAX_ELEMENTS` for missing / non-integer /
      +    sub-1 inputs so the cap can never be silently disabled by a malformed
      +    tool-call argument. Clamps oversized values to
      +    :data:`_MAX_ALLOWED_MAX_ELEMENTS` so a caller cannot bypass the
      +    safeguard by passing a very large integer.
      +    """
      +    if value is None:
      +        return _DEFAULT_MAX_ELEMENTS
      +    try:
      +        n = int(value)
      +    except (TypeError, ValueError):
      +        return _DEFAULT_MAX_ELEMENTS
      +    if n < 1:
      +        return _DEFAULT_MAX_ELEMENTS
      +    if n > _MAX_ALLOWED_MAX_ELEMENTS:
      +        return _MAX_ALLOWED_MAX_ELEMENTS
      +    return n
      +
      +
      +def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEMENTS) -> Any:
      +    total_elements = len(cap.elements)
      +    visible_elements = cap.elements[:max_elements]
      +    truncated_elements = max(0, total_elements - len(visible_elements))
      +
      +    # Index only what's actually surfaced in the response — otherwise the
      +    # human-readable summary references element indices the model cannot
      +    # find in the JSON `elements` array (e.g. max_elements=10 vs the default
      +    # 40-line index window).
      +    element_index = _format_elements(visible_elements)
           summary_lines = [
               f"capture mode={cap.mode} {cap.width}x{cap.height}"
               + (f" app={cap.app}" if cap.app else "")
               + (f" window={cap.window_title!r}" if cap.window_title else ""),
      -        f"{len(cap.elements)} interactable element(s):",
      +        f"{total_elements} interactable element(s):",
           ]
           if element_index:
               summary_lines.extend(element_index)
      +    # Multimodal and AX paths both reference `summary`; build it once up-front
      +    # so the aux-vision routing branch (which fires before either path is
      +    # selected) has a valid value to hand to _route_capture_through_aux_vision.
      +    # The AX path appends the "truncated to N of M" note to summary_lines
      +    # below and rebuilds; the multimodal path keeps this version untouched.
           summary = "\n".join(summary_lines)
       
           if cap.png_b64 and cap.mode != "ax":
      @@ -449,6 +495,9 @@ def _capture_response(cap: CaptureResult) -> Any:
               # JPEG: base64 starts with /9j/   PNG: starts with iVBOR
               _b64_prefix = cap.png_b64[:8]
               _mime = "image/jpeg" if _b64_prefix.startswith("/9j/") else "image/png"
      +        # The multimodal response carries the screenshot, not the AX
      +        # elements array, so a "response truncated to N of M elements"
      +        # note would be inaccurate — skip it on this branch.
               return {
                   "_multimodal": True,
                   "content": [
      @@ -458,18 +507,29 @@ def _capture_response(cap: CaptureResult) -> Any:
                   ],
                   "text_summary": summary,
                   "meta": {"mode": cap.mode, "width": cap.width, "height": cap.height,
      -                     "elements": len(cap.elements), "png_bytes": cap.png_bytes_len},
      +                     "elements": total_elements, "png_bytes": cap.png_bytes_len},
               }
      -    # AX-only (or image missing): text path.
      -    return json.dumps({
      +    # AX-only (or image-missing fallback): text path actually carries the
      +    # `elements` array, so the truncation note applies here.
      +    if truncated_elements:
      +        summary_lines.append(
      +            f"  (response truncated to {len(visible_elements)} of {total_elements} elements; "
      +            f"raise max_elements or pass app= to narrow)"
      +        )
      +    summary = "\n".join(summary_lines)
      +    payload: Dict[str, Any] = {
               "mode": cap.mode,
               "width": cap.width,
               "height": cap.height,
               "app": cap.app,
               "window_title": cap.window_title,
      -        "elements": [_element_to_dict(e) for e in cap.elements],
      +        "elements": [_element_to_dict(e) for e in visible_elements],
      +        "total_elements": total_elements,
               "summary": summary,
      -    })
      +    }
      +    if truncated_elements:
      +        payload["truncated_elements"] = truncated_elements
      +    return json.dumps(payload)
       
       
       # ---------------------------------------------------------------------------

1 PR closed without merging

From the close comments: merged via another change 1

  • #22891 fix(computer-use): cap AX `elements` array to prevent context blowup (#22865) · merged via another change → #30145, #30126, #22865
Closed issues · 1
  • Closed#22865computer_use capture can blow context on Electron/Obsidian AX trees
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means 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

At a glance

Merged PRs
#30126, #30145
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
22 May 2026

For your agent

The same facts, typed.