mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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
179 lines
6.8 KiB
Python
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())
|