Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions tests/tools/test_computer_use.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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 = ln.lstrip().split()[0].lstrip("#")
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
Expand Down
22 changes: 22 additions & 0 deletions tools/computer_use/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
78 changes: 69 additions & 9 deletions tools/computer_use/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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":
Expand All @@ -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": [
Expand All @@ -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)


# ---------------------------------------------------------------------------
Expand Down
Loading