Skip to content

Fix UI-TARS scroll parsing (scroll actions were silently dropped) - #63

Merged
marksibrahim merged 1 commit into
facebookresearch:mainfrom
jiayuww:fix/uitars-scroll-parsing
Jul 29, 2026
Merged

Fix UI-TARS scroll parsing (scroll actions were silently dropped)#63
marksibrahim merged 1 commit into
facebookresearch:mainfrom
jiayuww:fix/uitars-scroll-parsing

Conversation

@jiayuww

@jiayuww jiayuww commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Problem

uitars_parser (in src/open_apps/agent/utils.py) never translated scroll
actions into BrowserGym scroll(dx, dy) calls. The raw model string was passed
through unchanged, and since it isn't a valid BrowserGym action signature, the
scroll silently failed. Every UI-TARS-family model (UI-TARS, and any model
routed through this parser) effectively could not scroll.

Click and type are translated correctly, so any eval dominated by click/type
never surfaced this — it only bites tasks that require scrolling.

How to reproduce

UI-TARS-1.5 emits scroll actions in this exact form (verified across real run
trajectories — 59 scroll steps sampled, e.g. scroll(direction='down', point='(612,455)'),
scroll(direction='down', point='(1022, 666)'), scroll(direction='up', point='(1920,536)')):

from open_apps.agent.utils import flexible_parser

resp = "<think>scroll to see more</think><action>scroll(direction='down', point='(612,455)')</action>"
print(flexible_parser(resp)["action"])

Before this PR this prints:

scroll(direction='down', point='(612,455)')     # unchanged — NOT a valid scroll(dx, dy) call

The string reaches BrowserGym as-is; scroll expects positional scroll(dx, dy),
not direction=/point= kwargs, so the action fails / no-ops. Click and type in
the same trajectory translate fine, which is why this stayed hidden.

Root cause

The two scroll branches had mutually exclusive conditions, so neither body
ever executed:

# scroll(direction='down', point='(906,509)') -> scroll(dx, dy)
if result["action"].startswith("scroll(direction=d"):          # gate: char after '=' must be 'd'  -> NO quote
    direction = re.findall(
        r"scroll\(direction='(.*?)', point='\((\d+),(\d+)\)'\)", # regex: requires direction='...'  -> a quote
        result["action"])
    ...
  • The startswith("scroll(direction=d") gate requires the character right after
    = to be d (i.e. scroll(direction=down…, no quote).
  • The regex requires direction='…' (i.e. with a quote).

No string can satisfy both at once, so the branch is dead code (same for the
up branch).

What we changed

Replaced the two dead branches with a single tolerant parse that:

  1. matches any scroll(...) action,
  2. extracts the direction (down/up/left/right) and the first two
    integers regardless of quoting/whitespace, and
  3. emits an axis-aligned BrowserGym delta — down/right positive,
    up/left negative (a "scroll down" no longer also moves horizontally).

This also adds left/right, which the old code never handled.

if result["action"].startswith("scroll("):
    dir_match = re.search(
        r"direction\s*=\s*['\"]?(down|up|left|right)",
        result["action"],
        re.IGNORECASE,
    )
    nums = re.findall(r"-?\d+", result["action"])
    if dir_match and len(nums) >= 2:
        direction = dir_match.group(1).lower()
        x, y = int(nums[0]), int(nums[1])
        if direction == "down":
            dx, dy = 0, y
        elif direction == "up":
            dx, dy = 0, -y
        elif direction == "right":
            dx, dy = x, 0
        else:
            dx, dy = -x, 0
        result["action"] = f"scroll({dx}, {dy})"

Before / after (what the model emits -> parsed action)

model output before (broken) after (this PR)
scroll(direction='down', point='(612,455)') scroll(direction='down', point='(612,455)') (invalid) scroll(0, 455)
scroll(direction='up', point='(1920,536)') scroll(direction='up', point='(1920,536)') (invalid) scroll(0, -536)
scroll(direction='right', point='(300,400)') (unchanged / dropped) scroll(300, 0)
scroll(direction='left', point='(300,400)') (unchanged / dropped) scroll(-300, 0)

Click/type are unchanged:

model output parsed action
click(point='(100,200)') mouse_click(x=100, y=200)
type(content='hello\n') keyboard_type(text='hello\n')

Tests

Adds tests/test_uitars_parser.py — 6 tests covering all four scroll
directions, whitespace tolerance in the point, and click/type regressions.
All pass.

Notes

  • Coordinates are passed through as-is (raw pixels), matching the existing
    click-translation behavior.
  • The scroll magnitude uses the point's coordinate on the scroll axis. This is a
    heuristic (the point marks where to scroll, while BrowserGym scroll takes a
    delta), but it scrolls the correct direction by a bounded amount instead of
    failing outright. Happy to switch to a fixed step size if maintainers prefer.

uitars_parser never translated scroll actions to BrowserGym scroll(dx, dy):
the two branches had mutually-exclusive conditions — the gate
startswith("scroll(direction=d") requires no quote after '=' while the regex
required direction='...' with a quote. UI-TARS emits the quoted form
(e.g. scroll(direction='down', point='(612,455)'), verified in real traces),
so scroll was never translated and the raw string leaked to BrowserGym as an
invalid call — every UI-TARS-family model effectively could not scroll. Only
click/type are exercised by most evals, so it went unnoticed.

Replace the two dead branches with a single tolerant parse: extract the
direction and first two integers, then emit an axis-aligned delta
(down/right positive, up/left negative). Also handles left/right, which the
old code omitted. Add tests/test_uitars_parser.py covering all four
directions plus click/type regressions.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Jul 28, 2026
@marksibrahim

Copy link
Copy Markdown
Contributor

Great catch Jiayu! This looks great

@marksibrahim
marksibrahim merged commit b022471 into facebookresearch:main Jul 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants