← writings

Human-level on the benchmark, can’t click OK on a MacBook

Agent S is one of the best open-source computer-use agents you can run today. The latest version scores 66% on the OSWorld benchmark on its own and 72.6% with its best-of-N trick, which is around human level. I spent a week inside its codebase and shipped two fixes for issues that had been open for months. Neither touched a model or a prompt. Both were in the small amount of plain Python that sits between the models and the screen, and both were invisible on the benchmark.

That last part is what I want to write about. A benchmark is a very specific environment, and code that is correct inside it can be quietly wrong everywhere else. Here's how I found the two bugs, what caused them, how I fixed them, and what the benchmark was hiding. This is a companion to last week's post on three fixes to other computer-use tooling, and the shape of the lesson is similar.

PRIssueIn one line
Agent-S#228 #140 A normal screenshot made a request too big for a self-hosted model server to accept. The model was never reached.
Agent-S#231 #175 Clicks landed hundreds of pixels off on any screen that wasn't 1920x1080, and 11 pixels off even when it was.

What Agent S is and how it works

A computer-use agent is a program that gets things done the way a person does: look at the screen, decide what to do, move the mouse, type. I've written before about how these agents are really three separate things, and Agent S is a clean example. Under the hood it is a loop with two models in it.

The Agent S loop. Take a screenshot. The main model, for example GPT-5, sees it and writes a high-level action such as click the Firefox icon in the dock. The grounding model, for example UI-TARS-1.5, turns that description into x y coordinates. Plain Python converts those to screen pixels. pyautogui clicks. Repeat. take a screenshot main model e.g. GPT-5 "click the Firefox icon" grounding model e.g. UI-TARS-1.5 → (x, y) plain Python → screen pixels click repeat until the task is done both fixes live here
Two models and a little glue. The glue is where the bugs were.

Both of my fixes live in that glue. That is part of why the bugs were still open: the code looked too simple to be wrong.

Picking the issues

I wanted an issue that was high impact, affected real users, and had nobody working on it. The repository had 22 open issues with no assignee, but "no assignee" turned out to be a weak filter. Almost every meaningful one already had a pull request open from the person who reported it.

IssueTopicAlready had a PR?
#196Shell injection in the permission dialogYes, #197
#201eval() on model outputYes, #202
#199Prompt injection reaching exec()Yes, #219
#195hotkey('enter') typing e, n, t, e, rYes, #203 and #208
#168"At most 3 images may be provided"Yes, #211 and #226
#140"Failed to buffer the request body: length limit exceeded"No
#175"The problem of coordinate offset"Only a workaround (#177)

The two in bold stood out. Issue #140 was the most-discussed open issue in the repository, with nine comments from four separate people on Linux and Windows, a maintainer guessing at the cause, and nobody fixing it. Issue #175 had a PR against it, but that PR ran the grounding model several times and took the median, which smooths over noise rather than fixing a bug. Nobody had asked why the coordinates were off.

Bug 1: the request that was too big to send

PR: simular-ai/Agent-S#228 · Issue: #140

The symptom

People running UI-TARS on their own server got this on the very first step of every task:

🔄 Step 1/15: Getting next action from agent...
Response success!
...
(Grounded Action)
agent.click("The Firefox application icon ... at the very top of the left-side dock", 1, "left")

Attempt 1 failed: Failed to buffer the request body: length limit exceeded

Notice the order. The main model answered fine ("Response success!"). The failure happened on the next call, the one to the grounding model. One user said it also happened on the main model call when that model was self-hosted too.

A maintainer replied that it sounded like a problem with the user's UI-TARS setup and suggested trying a hosted provider instead. Later they added: "it might be screenshot being too big for request body?" That was the right instinct, but the thread went quiet.

Finding the cause

The error text is very specific, so I searched for it. "Failed to buffer the request body: length limit exceeded" is the exact response that Hugging Face's text-generation-inference server (TGI, the program that serves the model) returns, with HTTP status 413, when a request body is larger than its PAYLOAD_LIMIT. That limit defaults to 2,000,000 bytes. Hugging Face Inference Endpoints run that server, and so do many self-hosted setups.

So the question became: how big is the request Agent S sends?

Agent S captures a screenshot, resizes it so neither side is longer than 2400 pixels, saves it as PNG, base64-encodes it, and puts it inside the JSON body. Base64 is the standard way to put binary data inside text, and it makes anything about a third bigger. I couldn't take a real screenshot from my terminal, so I generated a desktop-like image: a 1920x1080 canvas full of text with scattered noise, which is roughly how a busy desktop compresses.

1920x1080 desktop-like PNG      1.90 MB
after base64                    2.53 MB
inside the JSON request body    2.53 MB   ← over the 2 MB limit

That's it. A normal 1080p screenshot of a normal desktop produces a request body that is over the limit before any model sees it. Clean, sparse screens compress better and squeeze through, which is why the bug felt random to the people hitting it.

A bar chart of request size against the 2 megabyte limit. The screenshot as PNG is 1.90 megabytes. After base64 encoding it is 2.53 megabytes, over the limit line. With the fix, JPEG at quality 90 is about 0.9 megabytes, and after base64 1.36 megabytes, under the line. how big is one screenshot in the request? server limit · 2.0 MB PNG on disk 1.90 MB PNG after base64 2.53 MB JPEG q90 on disk ~0.9 MB JPEG q90 after base64 1.36 MB before after, --max_image_mb 1.0
A busy 1080p desktop, measured. Base64 alone pushes a PNG screenshot over the default limit.

The request flow, before and after

Two sequences. Before: Agent S hands a 1.9 megabyte PNG screenshot to LMMAgent, which encodes messages and posts a 2.53 megabyte body to the self-hosted endpoint. The endpoint answers 413, failed to buffer the request body. The model is never reached. LMMAgent retries three times with the same result and the action fails. After, with max_image_mb 1.0: LMMAgent compresses the image to JPEG quality 90 at 0.9 megabytes and posts a 1.36 megabyte body. The endpoint forwards to the UI-TARS model, which answers with coordinates 1312, 904. 200 OK flows back to Agent S. BEFORE Agent S LMMAgent endpoint (TGI) UI-TARS screenshot PNG, 1.9 MB POST, body 2.53 MB 413 length limit exceeded model never reached retry ×3, same result, action fails AFTER · --max_image_mb 1.0 screenshot PNG, 1.9 MB compress → JPEG q90, 0.9 MB POST, body 1.36 MB forward "(1312, 904)" 200 OK coordinates
Before, the server bounced the body at the door. After, the same screenshot fits with room to spare.

The fix

I wanted something that was correct, did not change behaviour for anyone who was not affected, and gave people a clear path when they hit the error.

A size budget for images. I added a helper, compress_image_bytes(image_bytes, max_bytes), that takes an encoded image and a byte budget. If the image already fits, it is returned untouched, byte for byte. If it doesn't fit, the helper tries progressively cheaper encodings until one does.

The compression ladder. Start with image bytes and a budget. Fits already? Yes: return unchanged. No: re-encode as JPEG quality 90. Fits? Yes: return. No: step quality down 80, 70, 60, 50. Fits? Yes: return. No: shrink to 80 percent, keeping aspect ratio. Smaller than 30 percent of the original? No: go back to JPEG quality 90 at the new size. Yes: give up, return the smallest attempt and log a warning. image bytes, budget fits already? yes return unchanged no re-encode as JPEG quality 90 → 80 → 70 → 60 → 50 fits return still too big shrink to 80% size keep aspect ratio below 30% of original? no: try JPEG again at the new size yes give up return smallest attempt, log a warning quality first, resizing last
The ladder. Lower quality is cheap for UI screenshots. Fewer pixels costs real detail, so it comes last.

The order matters. Lowering JPEG quality costs very little visual fidelity on UI screenshots, but shrinking the image costs real detail, and the grounding model needs detail to find small icons. So quality goes first and resizing is a last resort.

Applying it everywhere images are sent. The class that builds messages for every model, LMMAgent, now reads an optional max_image_bytes from the engine parameters and runs every image through the helper before base64-encoding it. That covers single images, lists of images, and messages that get replaced later in the trajectory. Because it lives in one place, it applies to both the main model and the grounding model.

Honest MIME types. While doing this I noticed the data URLs were hardcoded as data:image/png;base64,... regardless of what the bytes actually were. Once the helper can produce a JPEG, that lie would matter. The code now detects the type from the bytes and labels it correctly, both for OpenAI-style data URLs and for Anthropic's media_type field.

A flag and a hint. The command line gets --max_image_mb, which is off by default so existing users see no change at all. And the retry loop, which previously printed "Attempt 1 failed" three times and gave up, now recognises this particular error and tells you what to do:

Attempt 1 failed: Failed to buffer the request body: length limit exceeded
Hint: The endpoint rejected the request body as too large. This usually means
the screenshot exceeds the server's payload limit (HuggingFace TGI defaults
to 2 MB). Re-run with --max_image_mb (e.g. --max_image_mb 1.0) or raise the
server's payload limit.

Why not just always compress?

I thought about making compression the default. I decided against it. The maintainers benchmark against hosted providers with no body limit, and silently switching everyone's screenshots from PNG to JPEG would change the inputs to their benchmark numbers. An opt-in flag with a loud hint fixes the problem for the people who have it and changes nothing for everyone else. That is an easier change to accept.

Tests

The helper is a pure function, so it is easy to test. The test file builds PNGs filled with random noise, because noise doesn't compress and so stays reliably over any budget I pick. It checks that:

Bug 2: the clicks that landed in the wrong place

PR: simular-ai/Agent-S#231 · Issue: #175

The symptom

The issue is short and polite: "I found that the process of using Agent S was not very precise. Especially when dealing with smaller buttons." Someone else replied with an analysis blaming model randomness and proposed running the grounding model several times and taking the median.

That would help with noise. But a model that is consistently off in one direction does not get fixed by averaging. I wanted to know whether the offset was random or systematic.

How coordinates are supposed to work

The grounding model Agent S recommends, UI-TARS-1.5, is built on Qwen2.5-VL. Models in that family do not see your image at its original size. The image processor first runs a step called smart_resize, which rounds both sides to multiples of 28 pixels and scales the whole image down if it has too many pixels. The model then answers with absolute pixel coordinates in that resized image.

ByteDance documents this precisely, and ships the conversion back to your original image:

new_x = int(model_x / resized_width  * original_width)
new_y = int(model_y / resized_height * original_height)

The key point: the coordinate space depends on the image you sent. Send a 1470x956 screenshot and the answer is in roughly 1470x956 space. Send 2400x1350 and it is in roughly 2400x1350 space.

What Agent S did instead

Agent S asks you to pass --grounding_width 1920 --grounding_height 1080 for UI-TARS-1.5, and the conversion was:

x_screen = model_x * screen_width  / grounding_width    # grounding_width  = 1920
y_screen = model_y * screen_height / grounding_height   # grounding_height = 1080

This is only right when the screenshot you sent was 1920x1080. On OSWorld, the benchmark, the virtual machine is always 1920x1080, so the formula happens to be correct there, and every benchmark number ever published for Agent S was produced under exactly that condition. On a real laptop the screenshot is whatever the screen is.

Three rows, one per screen. For each, the left box shows what the model actually sees after smart_resize, and the right box shows what Agent S assumed, always 1920 by 1080. MacBook Air: 1470 by 956, resized to 1484 by 952, versus 1920 by 1080. 1440p monitor: 2400 by 1350, resized to 2408 by 1344, versus 1920 by 1080. OSWorld VM: 1920 by 1080, resized to 1932 by 1092, versus 1920 by 1080, close but not equal. what the model sees what Agent S assumed MacBook Air 13" 1470 × 956 → 1484 × 952 ≠ 1920 × 1080 1440p monitor 2400 × 1350 → 2408 × 1344 ≠ 1920 × 1080 OSWorld VM 1920 × 1080 → 1932 × 1092 ≈ 1920 × 1080 close, not equal
The formula assumed the benchmark's screen. Only the bottom row is even close.

Measuring the damage

I simulated an element at 90% across and 95% down the screen, which is where "OK", "Save", and dock icons tend to live, and worked out what UI-TARS-1.5 would answer for each screen size and where Agent S would then click. The "after" column comes from running the real OSWorldACI class with a mocked model response, not a reimplementation.

ScreenScreenshot sentTargetBeforeAfter
OSWorld 1080p1920x1080(1728, 1026)(1739, 1037)(1728, 1026)
MacBook Air 13"1470x956(1323, 908)(1003, 800)(1323, 908)
MacBook Pro 16"1728x1117(1555, 1061)(1406, 1100)(1555, 1061)
1440p monitor2400x1350(2304, 1368)(2889, 1703)(2304, 1368)
4K monitor2400x1350(3456, 2052)(4334, 2554)(3456, 2052)

Three things jumped out.

  1. On a MacBook, clicks were about 300 pixels off horizontally. The agent was aiming at the wrong window.
  2. On 1440p and 4K monitors the click was off the screen entirely. pyautogui clamps to the screen edge, so the agent clicked the corner of the display every time it aimed at the bottom right.
  3. Even on the benchmark it was 11 pixels off. The 28-pixel rounding means the model answers in 1932x1092 space, not 1920x1080. Eleven pixels is nothing for a big button and everything for a 16-pixel icon. That is the "smaller buttons" the issue was about.

The offset was not noise. It was a fixed scaling error that grew with distance from the top-left corner.

A MacBook Air screen, 1470 by 956, drawn to scale. The target, an OK button near the bottom right at 1323, 908, is marked with a ring. Where Agent S actually clicked, at 1003, 800, is marked with a filled dot well up and to the left. An arrow between them shows the error. A caption says the error grows the further you are from the top-left corner. MacBook Air 13" · screen 1470 × 956 (0, 0) Save changes? OK target (1323, 908) clicked (1003, 800) ~320 px off
On a MacBook, aim at OK and hit the middle of the dialog. The error scales with distance from the corner.

The fix

The principle is simple: convert coordinates back from the space the model actually answered in, not from a space we assumed.

Before: model answer, multiplied by screen over a hardcoded 1920 by 1080, gives screen pixels. After: model answer goes through a decision, which coordinate space? If image, compute smart_resize of the size of the screenshot we sent. If fixed, use the grounding box, for example 1000 by 1000. Either way, multiply by screen over that size to get screen pixels. BEFORE model answer × screen / 1920×1080 (hardcoded) screen pixels AFTER model answer which space? image smart_resize(size of the screenshot we sent) fixed grounding box e.g. 1000 × 1000 × screen / that screen pixels
Before, one hardcoded denominator. After, the denominator is whatever space the model actually answered in.

Concretely:

Port the resize rule. I copied smart_resize and its helpers from the UI-TARS reference into Agent S, with the same constants (factor 28, minimum 100×28×28 pixels, maximum 16384×28×28 pixels). Now Agent S can compute the exact space the model answered in from the size of the image it sent.

Remember what was sent. generate_coords, the method that calls the grounding model, now records the size of the screenshot it is about to send. resize_coordinates uses that, passed through smart_resize, as the source space.

Handle both kinds of model. Not every grounding model answers in image pixels. UI-TARS-72B answers in a fixed 0 to 1000 space regardless of the image. So there are two modes:

Pick the mode automatically. I did not want to force a new flag on every user. The grounding box people already pass contains enough information: a pixel coordinate space always has the same shape as the screenshot, so a square box like 1000x1000 for a non-square screen cannot be a pixel space. It must be a normalized one.

So auto, the default, treats a square box as fixed and anything else as image. Both configurations in the README resolve correctly with no changes, and a new --grounding_coordinate_space image|fixed flag exists for anything unusual.

How auto mode decides. Left: a grounding box of 1920 by 1080 is wide, the same shape as a screen, so it is treated as an image pixel space. Right: a grounding box of 1000 by 1000 is square, which no screenshot is, so it must be a normalized fixed space. auto mode: the shape of the box gives it away 1920 × 1080 screen-shaped → image (pixels of the screenshot) 1000 × 1000 square → fixed (0 to 1000, normalized) no screen is square, so a square box can't be pixels
No new flag needed. Both configurations in the README resolve correctly on their own.

Fix the text-highlight action too. While reading the code I found a sibling bug. The highlight_text_span action runs OCR on the screenshot and uses the word boxes it gets back as screen coordinates directly, with no scaling at all. That is correct only when the screenshot is exactly the screen size. On a 1440p monitor, where the CLI caps screenshots at 2400 pixels wide, every text drag was in the wrong place. I added screenshot_to_screen and routed those coordinates through it.

A note on the benchmark

This change moves OSWorld coordinates by up to 11 pixels near the right and bottom edges. That is the 28-pixel rounding being handled correctly for the first time, and I called it out plainly in the PR because the maintainers will want to see a benchmark run before trusting it. Being upfront about a small, explainable change to benchmark inputs is better than having them discover it.

Tests

tests/test_grounding_coordinates.py has fourteen tests. They build an OSWorldACI with a fake API key, mock the grounding model call, and check the end-to-end conversion for:

What both bugs have in common

Neither bug is visible on OSWorld. The benchmark runs on a 1920x1080 virtual machine and talks to models through hosted providers with generous limits. Under those conditions the screenshot is small enough to send and the coordinate formula happens to be right. Every published result is correct. The code just was not general.

Two columns. Inside the benchmark: 1920 by 1080 virtual machine, hosted model providers with no body limit, so the screenshot fits and the coordinate formula happens to be right. Outside the benchmark: any laptop or monitor, self-hosted models with a 2 megabyte limit, so the screenshot is rejected and the coordinates are off. Both bugs sit in the gap between the columns. inside the benchmark screen: always 1920 × 1080 models: hosted, no body limit screenshot fits formula happens to be right 66% · 72.6% · all correct outside the benchmark screen: whatever you own models: self-hosted, 2 MB limit screenshot rejected at the door clicks 300 px off, or off-screen same code, wrong assumptions the benchmark held two variables constant. the code quietly depended on both.
Nothing about the published numbers is wrong. The code just assumed the room it was tested in.

Both bugs are in glue code, not in models or prompts. Request encoding and coordinate scaling are the kind of code that gets written once, works in the test environment, and is never looked at again because the interesting work is elsewhere.

And both had been diagnosed as something else. The payload error was blamed on users' endpoint setups. The coordinate offset was blamed on model randomness. In both cases the real cause was a sentence in someone else's documentation: Hugging Face's default payload limit, and ByteDance's description of their coordinate space.

How I worked

Read the issue thread, then verify every claim in it. The maintainer's guess that the screenshot was too big was right, but a guess in a comment is not a fix. Measuring the actual request body turned the guess into a number.

Reproduce before changing anything. For the payload bug, that meant building a realistic screenshot and measuring the encoded size. For the coordinate bug, it meant simulating what the model would return for five screen sizes and running it through the real conversion code. The before/after tables in both PRs came straight out of those scripts.

Write the test first. Each PR started with a failing test file. For the coordinate work in particular, writing the tests forced me to be precise about what "correct" meant for each screen size before I touched the conversion code.

Small commits with reasons in them. Each PR is five commits: helper, wiring, CLI flag, hint or related fix, docs. Every commit message says what changed and why, and references the issue. A reviewer can read the history and follow the reasoning without the PR description.

Change nothing for people who are not affected. Both fixes default to the existing behaviour wherever that behaviour was correct. The compression flag is off by default. The coordinate detection resolves the README's two documented configurations to the right mode with no flag changes. That is what makes a change to a popular repository easy to say yes to.

Say the uncomfortable thing in the PR. The coordinate fix shifts benchmark inputs by 11 pixels. I wrote that in a section called "Things worth knowing" rather than hoping nobody would notice.

The bigger lesson, for me, is about what a benchmark number means. Agent S at 66% on OSWorld is real and impressive. But the number describes one room: one screen size, one class of model host. The moment you take the agent out of that room and put it on a MacBook talking to a model you host yourself, two separate pieces of glue break before the first click lands. Most of the work of making agents useful is making them work outside the room, and a lot of that work looks exactly like this: unglamorous, in plain Python, and hiding behind an issue that everyone assumed was something else.

Appendix: numbers, commands, and references

Measured screenshot sizes

Desktop-like 1920x1080 image, text plus noise:

EncodingRawAfter base64
PNG1.90 MB2.53 MB
JPEG quality 90 (via the new helper, 1 MB budget)~0.9 MB1.36 MB

Hugging Face text-generation-inference default PAYLOAD_LIMIT: 2,000,000 bytes.

UI-TARS smart_resize constants

IMAGE_FACTOR = 28
MIN_PIXELS   = 100   * 28 * 28  =     78,400
MAX_PIXELS   = 16384 * 28 * 28  = 12,845,056

Examples of what the model actually sees:

SentAfter smart_resize
1920 x 10801932 x 1092
1470 x 9561484 x 952
1728 x 11171736 x 1120
2400 x 13502408 x 1344

Using the fixes

# Payload limit on a self-hosted endpoint
agent_s --provider openai --model gpt-5-2025-08-07 \
        --ground_provider huggingface --ground_url https://<endpoint>/v1 \
        --ground_model ui-tars-1.5-7b \
        --grounding_width 1920 --grounding_height 1080 \
        --max_image_mb 1.0

# Coordinate mapping is automatic. To force a mode:
agent_s ... --grounding_coordinate_space image   # pixels of the screenshot (UI-TARS-1.5)
agent_s ... --grounding_coordinate_space fixed   # fixed box, e.g. 0-1000 (UI-TARS-72B)

Files touched

PRFiles
#228gui_agents/s3/utils/common_utils.py, gui_agents/s3/core/mllm.py, gui_agents/s3/cli_app.py, README.md, tests/test_image_compression.py
#231gui_agents/s3/utils/common_utils.py, gui_agents/s3/agents/grounding.py, gui_agents/s3/cli_app.py, README.md, tests/test_grounding_coordinates.py

References

← more writings