120 lines
4.2 KiB
Python
120 lines
4.2 KiB
Python
"""Send-path eviction of stale vision_analyze / screenshot tool payloads.
|
|
|
|
Issue #89296: compression only retires older image-bearing tool results when
|
|
prune/compress fires, so OpenAI-style screenshots are re-serialized on every
|
|
later turn until a 413. ``evict_stale_outbound_tool_images`` is the
|
|
unconditional per-call chokepoint.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from agent.agent_runtime_helpers import sanitize_api_messages
|
|
from agent.context_compressor import (
|
|
_MAX_KEEP_TOOL_IMAGES,
|
|
_tool_content_has_images,
|
|
evict_stale_outbound_tool_images,
|
|
)
|
|
|
|
|
|
def _image_tool(i: int, *, blob: str = "A" * 80) -> list[dict]:
|
|
return [
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": f"call_{i}",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "vision_analyze",
|
|
"arguments": f'{{"image_url":"shot{i}.png"}}',
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": f"call_{i}",
|
|
"content": [
|
|
{"type": "text", "text": f"Image attached natively shot {i}"},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {"url": f"data:image/png;base64,{blob}{i}"},
|
|
},
|
|
],
|
|
},
|
|
]
|
|
|
|
|
|
def _history_with_screenshots(n: int) -> list[dict]:
|
|
msgs: list[dict] = [{"role": "user", "content": "look at these"}]
|
|
for i in range(n):
|
|
msgs.extend(_image_tool(i))
|
|
msgs.append({"role": "user", "content": "compare them"})
|
|
return msgs
|
|
|
|
|
|
def _image_bearing_tool_ids(messages: list[dict]) -> list[str]:
|
|
return [
|
|
m["tool_call_id"]
|
|
for m in messages
|
|
if m.get("role") == "tool" and _tool_content_has_images(m.get("content"))
|
|
]
|
|
|
|
|
|
class TestOutboundStaleVisionEviction:
|
|
def test_sanitize_alone_keeps_every_screenshot(self):
|
|
"""The previous send chokepoint does not close #89296 by itself."""
|
|
history = _history_with_screenshots(5)
|
|
sanitized = sanitize_api_messages(history)
|
|
assert _image_bearing_tool_ids(sanitized) == [f"call_{i}" for i in range(5)]
|
|
|
|
def test_eviction_keeps_only_newest_window(self):
|
|
history = _history_with_screenshots(5)
|
|
outbound = sanitize_api_messages(history)
|
|
pruned = evict_stale_outbound_tool_images(outbound)
|
|
assert pruned == 5 - _MAX_KEEP_TOOL_IMAGES
|
|
kept = _image_bearing_tool_ids(outbound)
|
|
assert kept == [f"call_{i}" for i in range(5 - _MAX_KEEP_TOOL_IMAGES, 5)]
|
|
|
|
oldest = next(m for m in outbound if m.get("tool_call_id") == "call_0")
|
|
assert isinstance(oldest["content"], list)
|
|
assert not _tool_content_has_images(oldest["content"])
|
|
assert any(
|
|
isinstance(part, dict)
|
|
and part.get("type") == "text"
|
|
and "screenshot removed" in str(part.get("text", ""))
|
|
for part in oldest["content"]
|
|
)
|
|
|
|
def test_does_not_rewrite_persisted_history(self):
|
|
from agent.conversation_loop import _clone_message_for_send
|
|
|
|
history = _history_with_screenshots(5)
|
|
outbound = [_clone_message_for_send(m) for m in history]
|
|
evict_stale_outbound_tool_images(outbound)
|
|
assert _image_bearing_tool_ids(history) == [f"call_{i}" for i in range(5)]
|
|
assert _image_bearing_tool_ids(outbound) == [
|
|
f"call_{i}" for i in range(5 - _MAX_KEEP_TOOL_IMAGES, 5)
|
|
]
|
|
|
|
def test_user_uploads_are_not_evicted(self):
|
|
history = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "look"},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {"url": "data:image/png;base64,USERUPLOAD"},
|
|
},
|
|
],
|
|
}
|
|
]
|
|
for i in range(_MAX_KEEP_TOOL_IMAGES + 2):
|
|
history.extend(_image_tool(i))
|
|
outbound = sanitize_api_messages(history)
|
|
evict_stale_outbound_tool_images(outbound)
|
|
user = next(m for m in outbound if m.get("role") == "user")
|
|
assert user["content"][1]["image_url"]["url"].endswith("USERUPLOAD")
|