fix: reuse compliant model image files without copying

This commit is contained in:
Soulter
2026-09-17 21:06:11 +08:00
parent f3ae8cda18
commit 4068b70fd4
7 changed files with 73 additions and 17 deletions
@@ -48,8 +48,9 @@ async def prepare_request_images(
output_dir=output_dir,
)
if image:
path, is_montage = image
event.track_temporary_local_file(path)
path, is_montage, needs_cleanup = image
if needs_cleanup:
event.track_temporary_local_file(path)
has_montage |= is_montage
prepared[ref] = path
if path:
+16 -9
View File
@@ -1197,8 +1197,8 @@ async def prepare_model_image(
*,
max_size: int,
output_dir: Path,
) -> tuple[str, bool] | None:
"""Prepare a single local model-ready image for the caller to own until consumption.
) -> tuple[str, bool, bool] | None:
"""Prepare an image, reusing compliant local files without copying them.
Args:
image_ref: Source reference accepted by MediaResolver.
@@ -1206,16 +1206,23 @@ async def prepare_model_image(
output_dir: Directory for event-owned working files.
Returns:
A preview path and whether it is an animation montage, or None for a
recoverable input or write failure. The caller must delete the preview
after use. The original image is never overwritten.
The image path, whether it is an animation montage, and whether the caller
must delete the file after use. Existing compliant local files are borrowed;
newly materialized sources and encoded previews are owned by the caller.
Returns None for a recoverable input or write failure.
"""
try:
async with MediaResolver(image_ref, media_type="image").as_path() as source:
image_bytes = await asyncio.to_thread(source.read_bytes)
converted_bytes, is_montage = await asyncio.to_thread(
_prepare_model_image_sync, image_bytes, max_size
)
converted_bytes, is_montage = await asyncio.to_thread(
_prepare_model_image_sync, image_bytes, max_size
)
if converted_bytes is image_bytes:
# The encoder returns the original bytes object for passthroughs.
# Transfer ownership only if the resolver materialized this file.
needs_cleanup = bool(source.cleanup_paths)
source.detach()
return str(source.path), is_montage, needs_cleanup
# Publish the working file synchronously after encoding, so cancellation
# cannot leave an untracked background write alive after this call.
output_dir.mkdir(parents=True, exist_ok=True)
@@ -1230,7 +1237,7 @@ async def prepare_model_image(
except BaseException:
output_path.unlink(missing_ok=True)
raise
return str(output_path), is_montage
return str(output_path), is_montage, True
except Exception as exc:
if not is_recoverable_image_error(exc):
raise
+1 -1
View File
@@ -1,4 +1,4 @@
# Unreleased
- Local Agent input images are always prepared as JPEG/PNG files strictly below 512 KiB. Transparent previews retain PNG alpha; animations become 3×3 montages. Original attachment paths remain available to tools, and event-owned previews are deleted after use without a shared conversion cache.
- Local Agent input images are always prepared as JPEG/PNG files strictly below 512 KiB. Compliant local images are reused without copying. Transparent previews retain PNG alpha; animations become 3×3 montages. Original attachment paths remain available to tools, and event-owned previews are deleted after use without a shared conversion cache.
- Configuration mapping: **Enable image compression** (`provider_settings.image_compress_enabled`) is replaced by always-on preparation; **JPEG quality** (`provider_settings.image_compress_options.quality`) is replaced by automatic size control; **Maximum edge length** (`provider_settings.image_compress_options.max_size`) remains available. User attachments in CUA sessions follow the same limits.
+1 -1
View File
@@ -24,7 +24,7 @@ Values of the removed settings in existing configuration files no longer affect
## Original files and preview lifetime
`image_urls` and image content parts use the prepared preview files. The attachment paths shown to the model for file access continue to point to the **original images**, so tools can read the originals. Original files and event image components are not overwritten.
`image_urls` and image content parts reuse compliant local JPEG/PNG files directly; no working copy is created. Images requiring conversion use prepared preview files. The attachment paths shown to the model for file access continue to point to the **original images**, so tools can read the originals. Original files and event image components are not overwritten.
Preview files are owned by the current event and deleted when the event finishes, including failure or cancellation. There is no shared image-conversion cache. Repeated references within one request reuse the same preview, including the preparation pass after the request hook.
+1 -1
View File
@@ -24,7 +24,7 @@ CUA 会话中的用户输入图片也遵守这些限制;截图及其他工具
## 原图路径与文件清理
`image_urls` 及图片内容块使用处理后的预览文件。提供给模型、供工具读取文件的附件说明仍然指向**原始图片路径**,不会替换为预览路径。原始文件和事件中的图片组件均不被覆盖。
`image_urls` 及图片内容块直接复用合规的本地 JPEG/PNG 文件,不创建工作副本;需要转换的图片才使用处理后的预览文件。提供给模型、供工具读取文件的附件说明仍然指向**原始图片路径**,不会替换为预览路径。原始文件和事件中的图片组件均不被覆盖。
模型预览文件归当前事件所有,事件完成、失败或取消后清理,不再保留跨请求图片转换缓存。同一请求中重复出现的图片引用复用同一份预览,请求 Hook 前后的准备过程也会去重。
+8 -3
View File
@@ -44,12 +44,17 @@ async def test_oversized_opaque_stills_become_jpeg_without_mutating_source(
@pytest.mark.asyncio
@pytest.mark.parametrize("fmt", ["JPEG", "PNG"])
async def test_compliant_stills_pass_through_byte_identical(tmp_path, fmt):
@pytest.mark.parametrize("file_uri", [False, True])
async def test_compliant_stills_reuse_source_without_copying(tmp_path, fmt, file_uri):
source = tmp_path / f"ok.{fmt.lower()}"
Image.new("RGB", (200, 100), "red").save(source, fmt)
original = source.read_bytes()
path = await _prepare_image_path(str(source), max_size=1280, output_dir=tmp_path)
assert path and Path(path).read_bytes() == original
output = tmp_path / "output"
prepared = await media.prepare_model_image(
source.as_uri() if file_uri else str(source), max_size=1280, output_dir=output
)
assert prepared == (str(source), False, False)
assert not output.exists()
assert source.read_bytes() == original
+43
View File
@@ -225,6 +225,11 @@ async def test_legacy_toggle_does_not_disable_preparation(
)
assert harness.provider.text_chat.await_count == 1
assert "image_settings" not in harness.provider.text_chat.await_args.kwargs
visual_path = Path(req.image_urls[0])
assert (visual_path == source) == (fmt in {"JPEG", "PNG"})
event.cleanup_temporary_local_files()
assert source.read_bytes() == original
assert visual_path.exists() == (fmt in {"JPEG", "PNG"})
@pytest.mark.asyncio
@@ -745,6 +750,44 @@ async def test_localized_reference_lifetime_and_ownership(
assert source.is_file()
@pytest.mark.asyncio
@pytest.mark.parametrize("fmt", ["JPEG", "PNG"])
@pytest.mark.parametrize("reference", ["data", "base64", "http", "file", "path"])
async def test_compliant_plugin_images_reuse_localized_file_with_correct_ownership(
harness, tmp_path, monkeypatch, fmt, reference
):
source = source_image(tmp_path, fmt)
original = source.read_bytes()
encoded = base64.b64encode(original).decode()
refs = {
"data": f"data:image/{fmt.lower()};base64," + encoded,
"base64": "base64://" + encoded,
"http": "https://example.com/source",
"file": source.as_uri(),
"path": str(source),
}
async def download(url, target):
Path(target).write_bytes(original)
monkeypatch.setattr(media, "download_file", download)
event = make_event()
event.set_extra(
"provider_request",
ProviderRequest(prompt="image", image_urls=[refs[reference]]),
)
await process_event(harness, event)
path = Path(harness.captured[0].req.image_urls[0])
assert path.read_bytes() == original
borrowed = reference in {"file", "path"}
assert (path == source) == borrowed
assert event._temporary_local_files == ([] if borrowed else [str(path)])
assert set(harness.work.rglob("*")) == (set() if borrowed else {path})
event.cleanup_temporary_local_files()
assert path.exists() == borrowed
assert source.read_bytes() == original
@pytest.mark.asyncio
async def test_two_plugin_requests_on_one_event_are_prepared_independently(
harness, tmp_path, monkeypatch