hermes-agent/tests/run_agent/repro_48013_image_shrink_brick.py
alt-glitch 990273d90a fix(agent): accept pixel-correct image downscale when bytes grow (#48013)
The image-too-large reactive shrink (try_shrink_image_parts_in_messages)
conflated two independent constraints: it always rejected a resize whose
re-encoded bytes were >= the original, even when the shrink was driven by a
PIXEL-DIMENSION cap (Anthropic many-image 2000px) rather than the byte budget.
Downscaled screenshot PNGs routinely re-encode LARGER in bytes, so the
dimension-correct result was discarded and the image left oversized -> the
provider re-rejected on retry and the session wedged forever.

Fix: track which constraint triggered the shrink (bytes vs dimension) and gate
the accept on the SAME axis.
  * dimension path: accept the result as long as it is now within max_dimension,
    regardless of byte size (verify via Pillow; fall back to the byte gate only
    when the re-encode can't be decoded).
  * bytes path: still require bytes to shrink, but ALSO re-check the per-side cap
    when it's active — _resize_image_for_vision returns a best-effort, possibly
    over-cap blob when it exhausts its halving budget on a very-high-aspect
    image, so a byte-shrink alone can leave it over the dimension cap and
    re-brick on retry.
Extend the unshrinkable-oversized guard to the pixel axis so a partial shrink
doesn't burn the one-shot retry.

Single shared agent path -> fixes CLI, TUI, and gateway alike.

Adds a real-Pillow runnable proof (repro_48013_image_shrink_brick.py) that
reproduces the issue's per-image table (bricks 3/5 before, passes 5/5 after)
plus unit invariants for the dimension and bytes accept/reject paths,
partial-progress accounting, and the bytes-path still-over-cap regression
surfaced by adversarial review.

Closes #48013
2026-06-19 11:37:51 -07:00

179 lines
6.8 KiB
Python

#!/usr/bin/env python3
"""Runnable proof for issue #48013 — image-dimension 400 session brick.
Before the fix, ``agent.conversation_compression.try_shrink_image_parts_in_messages``
silently discarded a *pixel-correct* downscale whenever the re-encoded PNG was
larger in bytes than the original (the common case for downscaled Retina
screenshots). The image was left at its original oversized dimensions, the
provider re-rejected it on retry, and the session wedged forever on the
Anthropic many-image 2000px path.
This script reproduces the exact scenario with REAL Pillow (no mocks): it
synthesizes screenshot-like PNGs at the dimensions from the issue's table —
images that are small in bytes (under the 4 MB budget) but over the 2000px
per-side cap — and runs the real recovery helper. It asserts every image is
brought under the cap and that the helper reports success.
Run directly to see a human-readable report:
python tests/run_agent/repro_48013_image_shrink_brick.py
Or as a pytest smoke test (skipped automatically when Pillow is absent):
scripts/run_tests.sh tests/run_agent/repro_48013_image_shrink_brick.py
"""
from __future__ import annotations
import base64
import io
import sys
from pathlib import Path
import pytest
# Make the repo root importable when run as a plain script.
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
PIL = pytest.importorskip("PIL", reason="Pillow required for the real-resize proof")
from PIL import Image, ImageDraw # noqa: E402
from agent.conversation_compression import ( # noqa: E402
try_shrink_image_parts_in_messages,
)
# The many-image per-side cap Anthropic reported in the wild (issue #48013).
MANY_IMAGE_CAP = 2000
BYTE_BUDGET = 4 * 1024 * 1024
# Dimensions straight from the issue's per-image table. The "REJECTED" rows
# are the ones that bricked: tall/large screenshots whose downscale re-encodes
# to MORE PNG bytes than the original.
CASES = [
(2344, 778), # wide — shrank even before the fix
(2374, 1144), # wide — shrank even before the fix
(2097, 1476), # REJECTED before fix
(2247, 1544), # REJECTED before fix
(2263, 1644), # REJECTED before fix
]
def _make_screenshot_png(width: int, height: int) -> bytes:
"""A screenshot-like PNG: mostly flat UI regions so it compresses small.
Flat regions keep the byte size well under the 4 MB budget, forcing the
DIMENSION path (not the byte path) — exactly the code that bricked. The
downscale of such an image re-encodes to a comparable-or-larger PNG, which
is what the old byte gate wrongly rejected.
"""
img = Image.new("RGB", (width, height), (245, 245, 247))
draw = ImageDraw.Draw(img)
for y in range(0, height, 40):
shade = 255 - (y // 40) % 6 * 4
draw.rectangle([20, y + 5, width - 20, y + 30], fill=(shade, 250, 250))
for x in range(0, width, 160):
draw.rectangle([x, 0, x + 2, height], fill=(220, 220, 225))
draw.text((40, 40), "Some UI text " * 30, fill=(20, 20, 20))
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=False)
return buf.getvalue()
def _data_url(raw: bytes) -> str:
return "data:image/png;base64," + base64.b64encode(raw).decode("ascii")
def _decode_dims(data_url: str) -> tuple[int, int]:
payload = data_url.partition(",")[2]
with Image.open(io.BytesIO(base64.b64decode(payload))) as img:
return img.size
def run_proof(verbose: bool = False) -> list[dict]:
"""Run the recovery against every case; return per-case results."""
results: list[dict] = []
for width, height in CASES:
raw = _make_screenshot_png(width, height)
url = _data_url(raw)
# Sanity: this case must be UNDER the byte budget and OVER the pixel cap,
# i.e. it exercises the dimension path that bricked.
under_byte_budget = len(url) <= BYTE_BUDGET
over_pixel_cap = max(width, height) > MANY_IMAGE_CAP
msgs = [{
"role": "user",
"content": [{"type": "image_url", "image_url": {"url": url}}],
}]
changed = try_shrink_image_parts_in_messages(
msgs, max_dimension=MANY_IMAGE_CAP,
)
out_url = msgs[0]["content"][0]["image_url"]["url"]
out_dims = _decode_dims(out_url)
result = {
"orig": (width, height),
"orig_bytes": len(raw),
"under_byte_budget": under_byte_budget,
"over_pixel_cap": over_pixel_cap,
"changed": changed,
"result_dims": out_dims,
"under_cap_after": max(out_dims) <= MANY_IMAGE_CAP,
}
results.append(result)
if verbose:
status = "OK" if result["under_cap_after"] else "BRICK"
print(
f" {width}x{height} ({len(raw)//1024:>3} KB)"
f" -> changed={changed!s:>5}"
f" result={out_dims[0]}x{out_dims[1]}"
f" [{status}]"
)
return results
def test_issue_48013_dimension_shrink_does_not_brick():
"""Every dimension-oversized screenshot must be brought under the cap."""
results = run_proof()
assert results, "no cases ran"
for r in results:
# Precondition: we really are on the dimension path.
assert r["under_byte_budget"], (
f"{r['orig']} must be under the byte budget to exercise the bug"
)
assert r["over_pixel_cap"], f"{r['orig']} must exceed the pixel cap"
# The fix: image lands under the cap and the helper reports success.
assert r["under_cap_after"], (
f"BRICK: {r['orig']} left at {r['result_dims']} "
f"(> {MANY_IMAGE_CAP}px) — the shrink recovery discarded a "
f"pixel-correct downscale (#48013)"
)
assert r["changed"] is True, (
f"{r['orig']} shrank but helper reported no progress — caller "
f"would surface the original error and burn the one-shot retry"
)
def main() -> int:
print("Issue #48013 proof — image-dimension shrink must not brick sessions")
print(f"(many-image per-side cap = {MANY_IMAGE_CAP}px, byte budget = "
f"{BYTE_BUDGET // (1024 * 1024)} MB)\n")
results = run_proof(verbose=True)
bricked = [r for r in results if not r["under_cap_after"]]
no_progress = [r for r in results if r["under_cap_after"] and not r["changed"]]
print()
if bricked:
print(f"FAIL: {len(bricked)} image(s) still over the pixel cap (BRICK).")
return 1
if no_progress:
print(f"FAIL: {len(no_progress)} image(s) shrank but helper reported "
f"no progress (would burn the retry).")
return 1
print(f"PASS: all {len(results)} dimension-oversized screenshots brought "
f"under {MANY_IMAGE_CAP}px and reported as progress.")
return 0
if __name__ == "__main__":
raise SystemExit(main())