Skip to content

fix(n2): send the whole screenshot history, pruning only to fit the wire cap - #374

Merged
juanpin merged 1 commit into
mainfrom
juanpin/n2-playground-parity
Sep 4, 2026
Merged

fix(n2): send the whole screenshot history, pruning only to fit the wire cap#374
juanpin merged 1 commit into
mainfrom
juanpin/n2-playground-parity

Conversation

@juanpin

@juanpin juanpin commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Why

The n2 loop windowed every request down to the two newest image-bearing messages and left [older image omitted] where each older frame had been.

That buys the model nothing. The API's n2 handler applies exactly that window itself before serving the model — keep_images_in_last_n_image_messages(messages, n=N2_IMAGE_MESSAGE_WINDOW), with N2_IMAGE_MESSAGE_WINDOW = 2 — and its comment calls it "the agreed n2 serving context policy (current + previous observation), not a provider constraint". api.md has told callers to "send the full conversation" for as long as the loop has been trimming it.

What the window did cost is the replay. The request log a run's replay is built from records exactly the messages the client sent, so a client that trims first is the only reason a macOS run's story stops after the last two frames while a playground run of the same length shows every step. This is the bug that started the investigation: [older image omitted] showing up in place of a run's history.

What changed

prune_n2_screenshots_to_budget replaces the window: send everything that fits under the 10 MB cap, drop oldest-first when it does not, never the newest. This is the reference harness's pruneScreenshotsToBudget (playground-core/src/server/computer-use-loop.ts), including:

  • its headroom — 64 KB, not 500 KB. The larger allowance only threw away frames that would have fit.
  • its silence — a dropped frame leaves no marker. That is what the server's own window does to the frames it strips; injecting a marker per dropped frame handed the model text the reference harness never produces. [older image omitted] stays the server's marker for its per-message image cap (cap_images_within_message), which is where it was trained.

retain_n2_image_window stays exported for a harness with its own reason to send less than it has, and as the executable statement of the server's window. fit_n2_request_images_to_budget is superseded and removed — it was neither exported from yutori.navigator nor documented in api.md.

prepare_n2_image_data_url now reads the media type off the data-URL header instead of base64-decoding the payload to compare a string. A request walks every frame each step now, and the common case is a pass-through compare.

Behaviour

Measured against synthetic 1920x1080 WebP q80 frames at ~121 KB each (a noisy worst case; real UI frames compress to roughly half that):

steps before dropped kept on the wire
20 2.50 MB 0 20 2.50 MB
50 6.26 MB 0 50 6.26 MB
100 12.51 MB 21 79 9.89 MB
200 25.03 MB 121 79 9.91 MB

Most runs never prune. A long one settles at the cap instead of losing its history.

The cost is upload. The playground pays this too, but it runs in-datacenter; a macOS run uploads from the user's own connection, so a run past ~50 steps sends several MB per step. That is what parity means here, and it is a deliberate trade — the alternative is the empty replay this PR is fixing.

Not a gap after all

I checked screenshotPolicy: 'on_demand' (playground #12806/#12810) and the loop already matches it: shell, browser-navigation and file calls return their text with no capture (the early returns in execute_n2_computer_call), and translate_n2_batch rejects shell/file/browser members outright, so a computer_batch is never all-non-visual. Existing tests already lock both halves in (test_current_file_tools_return_one_text_result_without_a_screenshot, and the bash-only turn in the harness test).

Test plan

  • pytest -m "not slow" — 865 passed, 9 skipped
  • ruff check . — clean
  • New coverage: a history that fits keeps every frame; oldest-first drops leave no marker; the newest frame is protected; extra images sharing the newest message are still droppable; the raise when even one frame cannot fit.
  • Rewritten: the loop-level test now asserts three frames for a three-click run, and the harness test asserts no marker anywhere in the request.

🤖 Generated with Claude Code


Note

Medium Risk
Changes n2 request payload size and replay fidelity (larger uploads on long runs) and replaces core message-prep logic; behavior is well-tested but affects every multi-step computer-use run.

Overview
Navigator n2 no longer windows outgoing requests to two image-bearing messages. N2ComputerAgent sends every screenshot from the run on each request (matching what the API replay log records); the server still applies its own two-message image window before the model sees the conversation.

Budget handling is replaced and tightened. fit_n2_request_images_to_budget is removed in favor of exported prune_n2_screenshots_to_budget, which mutates messages in place: drop oldest image parts first when serialized messages exceed the cap, never drop the newest observation, leave no [older image omitted] placeholder (dropped frames look like server-stripped history). Request headroom for non-message JSON shrinks from 500 KB to 64 KB so fewer frames are discarded unnecessarily.

Small performance/doc tweaks: prepare_n2_image_data_url checks the data-URL header for format before decoding; retain_n2_image_window stays public for harnesses; api.md and tests assert full-frame requests and the new prune semantics.

Reviewed by Cursor Bugbot for commit 305dbf8. Bugbot is set up for automated code reviews on this repo. Configure here.

…ire cap

The n2 loop windowed every request down to the two newest image-bearing
messages and left `[older image omitted]` where each older frame had been.
That buys the model nothing: the API's n2 handler applies exactly that window
itself (`keep_images_in_last_n_image_messages`, n=2) before it serves the
model, and documents it as the agreed serving context policy rather than a
provider constraint. api.md has told callers to "send the full conversation"
for as long as the loop has been trimming it.

What the window did cost is the replay. The request log a run's replay is
built from records exactly the messages the client sent, so a client that
trims first is the only reason a macOS run's story stops after the last two
frames while a playground run of the same length shows every step.

So drop the window and keep only the budget: `prune_n2_screenshots_to_budget`
sends everything that fits under the 10 MB cap and drops oldest-first when it
does not, never the newest. This is the reference harness's
`pruneScreenshotsToBudget`, including its headroom (64 KB, not 500 KB — the
larger allowance only threw away frames that would have fit) and its silence:
a dropped frame leaves no marker, which is what the server's own window does
to the frames it strips. Injecting a marker per dropped frame handed the model
text the reference harness never produces. `[older image omitted]` remains the
server's marker for its per-message image cap, which is where it was trained.

`retain_n2_image_window` stays exported for a harness with its own reason to
send less than it has, and as the executable statement of the server's window.
`fit_n2_request_images_to_budget` is superseded and removed; it was neither
exported from `yutori.navigator` nor documented in api.md.

Reading the media type off the data-URL header rather than decoding the
payload keeps the now-per-frame conversion pass cheap: a request walks every
frame each step, and the common case is a pass-through compare.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 305dbf8. Configure here.

if _drop_first_image(content) is None:
continue
dropped += 1
size_bytes = serialized_messages_bytes(messages)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Newest screenshot can be dropped

Medium Severity

prune_n2_screenshots_to_budget puts the newest message's content list into image_contents[:-1] whenever that message holds more than one image. After the first pass drains the extras, the second pass walks those same lists again and _drop_first_image can remove the remaining current observation instead of raising ValueError.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 305dbf8. Configure here.

@juanpin
juanpin merged commit be02059 into main Sep 4, 2026
16 checks passed
@juanpin
juanpin deleted the juanpin/n2-playground-parity branch September 4, 2026 06:52
@juanpin juanpin mentioned this pull request Sep 4, 2026
dhruvbatra added a commit that referenced this pull request Sep 4, 2026
_decode_data_url and _data_url_media_type each hand-rolled the identical
"data:...,...;base64..." validation and the same header[5:].split(";", 1)[0]
media-type extraction -- the second copy was added right alongside the first
in #374 (prune-to-budget) without noticing the first already existed a few
lines up. _decode_data_url now calls _data_url_media_type for validation and
media-type extraction, then does only its own base64 decode, so there is one
definition of what a valid n2 screenshot data URL looks like.

No public behavior change: both functions are module-private (not exported
from yutori.navigator), same exceptions raised under the same conditions,
same return values. Full non-slow suite (914 passed, 3 skipped) and the n2
payload/harness/compaction suites pass unmodified; ruff check/format clean.


Claude-Session: https://claude.ai/code/session_01DvN8Ceba13sWCcsPbFmBUP

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant