Skip to content
Open
456 changes: 402 additions & 54 deletions deploy/modal/modal_cua_server.py

Large diffs are not rendered by default.

61 changes: 46 additions & 15 deletions src/mantis_agent/brain_holo3.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,26 @@ def _build_messages(

content: list[dict] = []
n_frames = len(frames)
# Aim diagnostic (WARNING so it survives Modal log suppression): the
# image the model localizes on vs the screen_size the coord-mapping
# assumes. If the CURRENT frame is TALLER than screen_size the model
# over-emits y and _model_coords_to_screen (which divides by the
# smart-resize of screen_size) can dispatch off-screen. Pairs with the
# raw coord in modelio ``response.text`` and the mapped/grounded coord
# in the runner's ``[cua-offset]`` line to fully explain any y-miss.
if n_frames and hasattr(frames[-1], "size"):
_cw, _ch = frames[-1].size
if (_cw, _ch) != (screen_size[0], screen_size[1]):
logger.warning(
"[cua-imgdim] current_frame=%sx%s screen_size=%sx%s "
"(MISMATCH → coord space skewed)",
_cw, _ch, screen_size[0], screen_size[1],
)
else:
logger.warning(
"[cua-imgdim] current_frame=%sx%s screen_size=%sx%s (match)",
_cw, _ch, screen_size[0], screen_size[1],
)
for i, frame in enumerate(frames):
label = "CURRENT" if i == n_frames - 1 else f"t-{n_frames - 1 - i}"
content.append({"type": "text", "text": f"[Frame {label}]"})
Expand Down Expand Up @@ -759,15 +779,27 @@ def _has_valid_xy(args: dict) -> bool:
explicit ``(0, 0)`` both indicate the model didn't pin a
target — better to fall through to the WAIT(1s) fallback
than dispatch to the page origin.

Validate as FLOAT, not ``_safe_int``: Holo3's pure-CUA output
emits NORMALIZED fractions like ``click(x=0.324, y=0.338)``.
``_safe_int`` truncated 0.324 → 0, so a perfectly good click
read as the (0,0) origin and got rejected — the harness then
substituted WAIT(1s) and silently dropped an on-policy action
(cua-issues 2026-06-29 S09, native-text path). ``_coords_to_screen``
(used below) scales the fraction; here we only gate on it being
a real, non-origin target.
"""
if "x" not in args or "y" not in args:
return False
x = _safe_int(args["x"], default=-1)
y = _safe_int(args["y"], default=-1)
if x < 0 or y < 0:
# Malformed value that didn't parse to a non-negative int.
try:
fx = float(str(args["x"]).strip())
fy = float(str(args["y"]).strip())
except (ValueError, TypeError):
# Malformed value that didn't parse to a number.
return False
if fx < 0 or fy < 0:
return False
return (x, y) != (0, 0)
return (fx, fy) != (0.0, 0.0)

# Map to Action
if func_name in ("click",):
Expand All @@ -780,17 +812,17 @@ def _has_valid_xy(args: dict) -> bool:
# level → clean no-op step.
if not _has_valid_xy(args):
return None
x = _safe_int(args["x"])
y = _safe_int(args["y"])
sx, sy = _model_coords_to_screen(x, y, screen_size[0], screen_size[1])
# #S09: normalized-fraction-aware conversion (same helper the
# tool_calls path uses via _convert_coords). Handles both
# ``0.324`` fractions and resized-space integers — do NOT
# pre-truncate with _safe_int (that reintroduces the origin bug).
sx, sy = self._coords_to_screen(args["x"], args["y"], screen_size)
return Action(ActionType.CLICK, {"x": sx, "y": sy, "button": args.get("button", "left")})

if func_name in ("double_click", "doubleclick"):
if not _has_valid_xy(args):
return None
x = _safe_int(args["x"])
y = _safe_int(args["y"])
sx, sy = _model_coords_to_screen(x, y, screen_size[0], screen_size[1])
sx, sy = self._coords_to_screen(args["x"], args["y"], screen_size)
return Action(ActionType.DOUBLE_CLICK, {"x": sx, "y": sy})

if func_name in ("type_text", "type", "typewrite"):
Expand Down Expand Up @@ -848,14 +880,13 @@ def _parse_json_action(self, text: str, screen_size: tuple[int, int]) -> Action
return self._parse_holo3_action(code, screen_size)

if action_type == "click":
x, y = int(obj.get("x", 0)), int(obj.get("y", 0))
sx, sy = _model_coords_to_screen(x, y, screen_size[0], screen_size[1])
# #S09: normalized-fraction-aware (int() truncated 0.324 → 0).
sx, sy = self._coords_to_screen(obj.get("x", 0), obj.get("y", 0), screen_size)
button = obj.get("button", "left")
return Action(ActionType.CLICK, {"x": sx, "y": sy, "button": button})

if action_type in ("double_click", "doubleclick", "doubleClick"):
x, y = int(obj.get("x", 0)), int(obj.get("y", 0))
sx, sy = _model_coords_to_screen(x, y, screen_size[0], screen_size[1])
sx, sy = self._coords_to_screen(obj.get("x", 0), obj.get("y", 0), screen_size)
return Action(ActionType.DOUBLE_CLICK, {"x": sx, "y": sy})

if action_type in ("type", "type_text", "typewrite", "write"):
Expand Down
33 changes: 32 additions & 1 deletion src/mantis_agent/gym/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -1630,6 +1630,22 @@ def _save_frame(img: Any, step: int) -> None:
initial_x=orig_x,
initial_y=orig_y,
)
# Offset measurement (WARNING so it survives Modal
# log suppression): the model's raw click coord vs
# the grounded (actual-element) coord IS the exact
# per-click offset. Uniform ~chrome-height vertical
# → a systematic mapping shift; large/variable 2D →
# Holo3 spatial-grounding imprecision.
try:
_dx = int(gr.x) - int(orig_x)
_dy = int(gr.y) - int(orig_y)
except (TypeError, ValueError):
_dx = _dy = None
logger.warning(
"[cua-offset] model=(%s,%s) grounded=(%s,%s) "
"delta=(%s,%s) conf=%.2f",
orig_x, orig_y, gr.x, gr.y, _dx, _dy, gr.confidence,
)
if gr.confidence > 0.3:
print(f" [grounding] ({orig_x},{orig_y}) → ({gr.x},{gr.y}) conf={gr.confidence:.1f}")
action = Action(
Expand Down Expand Up @@ -1683,6 +1699,10 @@ def _save_frame(img: Any, step: int) -> None:
# screenshot the brain reasoned on, so it's the right
# baseline for "did this action change the page?".
pre_action_frame = frame_history[-1] if frame_history else None
# Snapshot the pre-action URL before ``last_url`` is reassigned
# to the post-action URL below — the URL-diff guard in the
# perceptual-diff block needs the BEFORE value.
_pre_action_url = last_url

for pre_action in pre_actions:
logger.warning(
Expand Down Expand Up @@ -1903,12 +1923,23 @@ def _save_frame(img: Any, step: int) -> None:
and isinstance(focused_input, dict)
and focused_input.get("focused_via_click")
)
# URL-diff guard: a click that NAVIGATED is a real change even
# when the perceptual-diff sees near-identical frames (the new
# page renders after the post-action capture, or simply looks
# similar). Firing "no observed effect" here contradicts the
# "page navigated to <url>" line ``_build_feedback`` already
# added and traps otherwise-recoverable trajectories in
# Return-loops — the single largest rollout-quality depressor.
# Uses the URL the env already surfaces in ``info`` (post-action
# verification, not target derivation).
_post_url = str(gym_result.info.get("url", "") or "")
_navigated_this_step = bool(_post_url and _post_url != _pre_action_url)
if clicked_into_field:
feedback = (
f"{feedback}; input field focused — type your text now "
f"(do not click it again)"
)
elif effect_check.effect_observed is False:
elif effect_check.effect_observed is False and not _navigated_this_step:
feedback = (
f"{feedback}; WARNING: high-risk action had no observed effect "
f"({effect_check.reason})"
Expand Down
107 changes: 107 additions & 0 deletions src/mantis_agent/gym/xdotool_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,81 @@ def _cdp_insert_text(self, text: str) -> bool:
ok, _ = self._cdp_call("Input.insertText", {"text": text})
return ok

def cdp_input_insert(self, text: str) -> bool:
"""Focus-hardened fill for ``<textarea>`` / ``<input>`` — the analogue
of :meth:`cdp_contenteditable_insert` for plain form fields.

The TYPE ladder (Input.insertText / clipboard-paste / xdotool) all fire
at ``document.activeElement`` and lose the race when focus drifts
between the focusing click and the keystrokes — the connect-note
textarea "never fills", the post composer "loses focus mid-type". This
re-establishes focus on the field and sets its value ATOMICALLY via the
native value setter + an ``input`` event (React-safe *and*
plain-textarea-safe), then reads it back to verify.

Target resolution: the field the model CLICKED (``elementFromPoint``
at the last click, screen→viewport-adjusted) → the focused field → a
``:focus`` field. It deliberately does NOT guess "the only visible
input" — that filled the search bar when the intended field hadn't
opened. Returns ``False`` (caller falls back to the TYPE ladder) when
CDP is unavailable, no field is resolvable, or the read-back shows the
text did not land. Action-side dispatch of a vision-derived fill +
post-action verify, not DOM grounding.
"""
eval_fn = getattr(self, "cdp_evaluate", None)
if not callable(eval_fn):
return False
# Last-clicked point in VIEWPORT coords: elementFromPoint is
# viewport-relative, but the click was in full-window screen space, so
# subtract the chrome band (same translation cdp_click_at_point uses).
lx, ly = getattr(self, "_last_click_xy", (None, None))
if isinstance(lx, int) and isinstance(ly, int):
vx, vy = lx, ly - self._chrome_offset_px()
else:
vx = vy = -1
text_js = json.dumps(text)
try:
result = eval_fn(
"(() => {"
"const isField = e => e && (e.tagName === 'TEXTAREA' || "
"(e.tagName === 'INPUT' && "
"!/^(button|submit|reset|checkbox|radio|hidden|file|image)$/i"
".test(e.type || 'text')));"
f"const vx = {vx}, vy = {vy};"
"let el = null;"
# 1) The field the model CLICKED — target it directly, so we
# never fill a wrong field just because it was the only visible
# one (the search-bar mis-fill).
"if (vx >= 0 && vy >= 0) {"
" const hit = document.elementFromPoint(vx, vy);"
" if (hit) {"
" el = isField(hit) ? hit : "
" (hit.closest ? hit.closest('textarea, input') : null);"
" if (!isField(el)) el = null;"
" }"
"}"
# 2) Fall back to the focused field only.
"const a = document.activeElement;"
"if (!el && isField(a)) el = a;"
"if (!el) el = document.querySelector('textarea:focus, input:focus');"
"if (!isField(el)) return {ok:false};"
"el.focus();"
"const proto = el.tagName === 'TEXTAREA' ? "
"HTMLTextAreaElement.prototype : HTMLInputElement.prototype;"
"const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;"
f"setter.call(el, {text_js});"
"el.dispatchEvent(new Event('input', {bubbles:true}));"
"el.dispatchEvent(new Event('change', {bubbles:true}));"
"return {ok:true, value: String(el.value == null ? '' : el.value)};"
"})()"
)
except Exception as exc: # noqa: BLE001 — never fatal; caller falls back
logger.debug("cdp_input_insert raised: %s", exc)
return False
if not isinstance(result, dict) or not result.get("ok"):
return False
return self._type_matches(text, str(result.get("value") or ""))

def cdp_contenteditable_insert(self, text: str) -> bool:
"""#931 P1: insert ``text`` into the focused contenteditable host and
**verify it landed**, returning ``False`` if it did not.
Expand Down Expand Up @@ -1723,6 +1798,19 @@ def step(self, action: Action) -> GymResult:
verdict = self._verify_typed_text(typed) or verdict
except Exception as exc: # noqa: BLE001 — never fatal
logger.debug("TYPE contenteditable fallback raised: %s", exc)
# contenteditable insert self-gates to editable <div>s; plain
# <textarea>/<input> (connect-note, post composer) need the
# focus-hardened value-set. Fire it when the text STILL hasn't
# landed — converts the "textarea never filled" / "force-fill
# loses focus mid-type" failures into reliable fills.
if verdict is not None and not verdict.get("success"):
input_inserter = getattr(self, "cdp_input_insert", None)
if callable(input_inserter):
try:
if input_inserter(typed):
verdict = self._verify_typed_text(typed) or verdict
except Exception as exc: # noqa: BLE001 — never fatal
logger.debug("TYPE input fallback raised: %s", exc)
if verdict is not None:
info["type_verified"] = verdict
elif action.action_type in (ActionType.CLICK, ActionType.DOUBLE_CLICK):
Expand All @@ -1745,6 +1833,20 @@ def step(self, action: Action) -> GymResult:
"focused_via_click": True,
"value": str(probe.get("text") or ""),
}

# Post-action URL so the runner's navigation-aware feedback works.
# ``_build_feedback`` (and the perceptual-diff URL-diff guard) read
# ``info["url"]`` to emit "page navigated to <url>" and to SUPPRESS the
# false "clicked (no visible change)" on a real navigation — the single
# largest rollout-quality depressor. Without this the info dict never
# carries a URL, so every navigating click reads as a no-op and the
# brain thrashes the step budget in Return-loops. Best-effort CDP read
# (post-action verification, not target derivation).
try:
if hasattr(self, "current_url"):
info["url"] = self.current_url or ""
except Exception: # noqa: BLE001 — feedback URL is best-effort
pass
return GymResult(observation=obs, reward=0.0, done=False, info=info)

def close(self) -> None:
Expand Down Expand Up @@ -2003,13 +2105,18 @@ def _execute_action(self, action: Action) -> None:
if self._human_speed:
time.sleep(random.uniform(0.05, 0.15))
self._xdotool("click", btn_num)
# Remember where we clicked so a following TYPE can target the
# field the model actually clicked (via elementFromPoint), not
# a guessed one — see ``cdp_input_insert``. Screen-space coords.
self._last_click_xy = (x, y)

case ActionType.DOUBLE_CLICK:
x = action.params.get("x", self._viewport[0] // 2)
y = action.params.get("y", self._viewport[1] // 2)
x, y = self._clamp(x, y)
self._mousemove_with_curve(x, y)
self._xdotool("click", "--repeat", "2", "1")
self._last_click_xy = (x, y)

case ActionType.TYPE:
text = action.params.get("text") or action.params.get("content") or ""
Expand Down
52 changes: 52 additions & 0 deletions tests/test_contenteditable_931.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,55 @@ def test_step_type_fallback_keeps_failure_when_insert_cannot_land(monkeypatch):
monkeypatch.setattr(env, "cdp_contenteditable_insert", lambda t: False, raising=False)
res = env.step(Action(action_type=ActionType.TYPE, params={"text": "hi"}))
assert res.info["type_verified"]["success"] is False


# ── cdp_input_insert (textarea/input focus-hardened fill) ───────────────


def test_input_insert_success_when_value_set_and_readback_confirms():
"""Focus-hardened value-set lands and the read-back shows the text →
True. Fixes the connect-note textarea that "never fills" and the post
composer that "loses focus mid-type"."""
env = _env()
env.cdp_evaluate = lambda js: {"ok": True, "value": "hello world"}
assert env.cdp_input_insert("hello world") is True


def test_input_insert_false_when_no_field_resolvable():
env = _env()
env.cdp_evaluate = lambda js: {"ok": False} # no textarea/input in focus or singleton
assert env.cdp_input_insert("hello") is False


def test_input_insert_false_when_text_never_lands():
"""Read-back empty → False so the caller doesn't report a filled field."""
env = _env()
env.cdp_evaluate = lambda js: {"ok": True, "value": ""}
assert env.cdp_input_insert("hello") is False


def test_input_insert_false_when_cdp_unavailable():
env = _env()
env.cdp_evaluate = "not callable" # no CDP
assert env.cdp_input_insert("hello") is False


def test_input_insert_targets_clicked_point_not_single_visible():
"""The tightened resolution targets elementFromPoint(last click,
screen→viewport) and does NOT guess "the only visible field" — that filled
the search bar when the intended field hadn't opened."""
env = _env()
env._last_click_xy = (491, 300)
env._chrome_offset_px = lambda: 92
captured = {}

def _eval(js):
captured["js"] = js
return {"ok": True, "value": "hi there"}
env.cdp_evaluate = _eval

assert env.cdp_input_insert("hi there") is True
js = captured["js"]
assert "elementFromPoint" in js # clicked-field targeting
assert "vx = 491" in js and "vy = 208" in js # 300 − 92 chrome offset
assert "offsetParent" not in js # single-visible guess removed
Loading
Loading