fix: harden image resource lifecycle

This commit is contained in:
wang-qisen 2026-09-04 18:17:53 +08:00
parent 4785463f4b
commit 1467c204aa
9 changed files with 614 additions and 99 deletions

View file

@ -36,7 +36,8 @@ In short, it turns "a file was archived" into "the resource is usable."
Auto Resource uses `resource/` as the entry point for source material. Date directories are recommended, and their date
determines which daily memory layer receives the interpreted card. A file directly under `resource/` is also supported
and uses today in the application timezone.
and uses today in the application timezone when it is first processed. On later days, an exact `source_resource` match
keeps updates and deletion tied to that original daily card instead of creating a new card or leaving an orphan.
Example directory:
@ -65,10 +66,14 @@ The card body starts with an `![[resource/...]]` embed link and the frontmatter
so text search reaches image content through the caption.
The vision model is the `vision` instance of `as_llm` when configured, and otherwise falls back to the `default`
instance — a multimodal default model needs no extra configuration. Images larger than the request budget or in
provider-unfriendly formats are downscaled or re-encoded in memory for the request only; the original file under
`resource/` is never modified. When an image changes, its card is rewritten in place; when the image is deleted, the
card is removed with it.
instance — a multimodal default model needs no extra configuration. Images wider or taller than 2048px are downscaled,
and provider-unfriendly formats are re-encoded, in memory for the request only; the original file under
`resource/` is never modified. Before a full decode, image dimensions are checked against a default limit of 40,000,000
pixels; images over the limit and Pillow decompression-bomb warnings fail only that resource. EXIF orientation is
applied to the in-memory request copy before resizing or conversion. Oversized JPEGs first use decoder-level
downsampling, followed by a final thumbnail pass when needed. The VLM request MIME and the card's frontmatter
`media_type` use the format Pillow detects from the image bytes, rather than trusting the filename extension. When an
image changes, its card is rewritten in place; when the image is deleted, the card is removed with it.
Image preprocessing uses Pillow from the `core` extra. HEIC resources additionally require the optional
`image-heif` extra: `pip install "reme-ai[image-heif]"`. Other supported image formats do not load or require the HEIF

View file

@ -185,8 +185,8 @@ reme auto_memory \
```
After placing external material under `resource/YYYY-MM-DD/` or directly under `resource/`, the default background task
watches
`md/txt/json/jsonl/csv/yaml/html`. You can also trigger processing manually:
watches text resources (`md/txt/json/jsonl/csv/yaml/html`) and image resources
(`png/jpg/jpeg/webp/gif/bmp/tiff/heic`). You can also trigger processing manually:
```bash
reme auto_resource changes='[{"path":"resource/2026-06-20/report.md","change":"added"}]'

View file

@ -33,7 +33,8 @@ resource/[YYYY-MM-DD/]<resource_file>
## 原始资料入口
Auto Resource 以 `resource/` 作为原始资料入口。推荐按日期放置,目录日期会决定它进入哪一天的 daily 记忆层;也支持直接放在
`resource/` 根目录,此时使用应用时区中的今天。
`resource/` 根目录,首次处理时使用应用时区中的今天。之后即使跨天更新或删除,也会通过精确匹配
`source_resource` 继续操作原 daily 卡片,不会重复新建卡片或留下孤立链接。
示例目录:
@ -56,7 +57,13 @@ workspace/
图像文件的解读方式相同:视觉模型写入一张 caption 卡片并链接原图。卡片正文以 `![[resource/...]]` 嵌入链接开头frontmatter 携带 `kind: image``media_type`,文本检索因此可以通过 caption 命中图像内容。
视觉模型优先使用配置中的 `as_llm` `vision` 实例,未配置时回退到 `default` 实例——默认模型具备视觉能力时无需额外配置。超过请求预算或格式不被模型接受的图像,仅在请求前于内存中降采样或转码;`resource/` 下的原图文件不会被修改。图像变更时卡片原地重写;图像删除时卡片随之删除。
视觉模型优先使用配置中的 `as_llm` `vision` 实例,未配置时回退到 `default` 实例——默认模型具备视觉能力时无需额外配置。宽或高超过 2048px 的图像会降采样,格式不被模型接受的图像会转码;这些处理只发生在请求前的内存副本中,`resource/` 下的原图文件不会被修改。图像变更时卡片原地重写;图像删除时卡片随之删除。
在完整解码前,系统会检查图像尺寸,默认上限为 40,000,000 像素;超限图像或 Pillow
decompression-bomb 警告只会导致当前资源失败。缩放或转码前,会按 EXIF orientation 校正仅用于请求的内存副本。
尺寸过大的 JPEG 会先使用 decoder-level downsampling并在需要时再完成最终缩放。
VLM 请求的 MIME 和卡片 frontmatter 中的 `media_type` 都使用 Pillow 根据实际图像字节识别的格式,
而不是直接信任文件扩展名。
图像预处理使用 `core` extra 中的 Pillow。HEIC 资源还需要可选的 `image-heif` extra
`pip install "reme-ai[image-heif]"`。其他受支持图像格式不会加载或依赖 HEIF 插件。

View file

@ -178,7 +178,8 @@ reme auto_memory \
memory_hint="记录用户偏好"
```
外部资料放入 `resource/YYYY-MM-DD/` 或直接放在 `resource/` 下后,默认后台会监听 `md/txt/json/jsonl/csv/yaml/html`
外部资料放入 `resource/YYYY-MM-DD/` 或直接放在 `resource/` 下后,默认后台会监听文本资源
(`md/txt/json/jsonl/csv/yaml/html`) 和图像资源 (`png/jpg/jpeg/webp/gif/bmp/tiff/heic`)。
也可以手动触发:
```bash

View file

@ -4,6 +4,7 @@ import base64
import io
import json
import re
import warnings
from pathlib import Path, PurePosixPath
import aiofiles
@ -11,17 +12,22 @@ from agentscope.message import Base64Source, DataBlock, TextBlock, UserMsg
from agentscope.model import ChatModelBase
from pydantic import BaseModel, Field
from ..file_io._path import IMAGE_MIME_BY_EXT, IMAGE_SUFFIXES
from ..file_io._path import IMAGE_SUFFIXES
from .base_auto_resource import _SOURCE_RESOURCE_KEY, _sanitize_note_name, BaseAutoResourceStep
from ...components import R
from ...enumeration import ComponentEnum
DEFAULT_MAX_IMAGE_INPUT_BYTES = 50 * 1024 * 1024
DEFAULT_MAX_IMAGE_PIXELS = 40_000_000
MAX_IMAGE_REQUEST_DIMENSION = 2048
_JPEG_QUALITY = 85
# Suffixes re-encoded to provider-friendly PNG/JPEG for VLM requests;
# the stored resource file is never modified.
_CONVERT_SUFFIXES = {".bmp", ".tiff", ".heic"}
# Decoded formats outside this set are re-encoded to provider-friendly
# PNG/JPEG for VLM requests; the stored resource file is never modified.
_PASSTHROUGH_IMAGE_MIMES = frozenset({"image/png", "image/jpeg", "image/webp", "image/gif"})
_HEIF_BRANDS = frozenset(
{b"heic", b"heif", b"heix", b"heim", b"heis", b"hevc", b"hevx", b"hevm", b"hevs", b"mif1", b"msf1"},
)
_MAX_FTYP_SCAN_BYTES = 4096
_JSON_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*(.*?)\s*```\s*$", re.DOTALL)
@ -37,84 +43,170 @@ class _CaptionOutput(BaseModel):
)
def _load_pillow(suffix: str):
"""Load Pillow and optional HEIC support only when image processing runs."""
def _load_pillow():
"""Load the core image dependency only when image processing runs."""
try:
from PIL import Image # pylint: disable=import-outside-toplevel
from PIL import Image, ImageOps # pylint: disable=import-outside-toplevel
except ImportError as exc:
raise RuntimeError("Image captioning requires Pillow; install reme-ai[core]") from exc
if suffix == ".heic":
try:
from pillow_heif import register_heif_opener # pylint: disable=import-outside-toplevel
except ImportError as exc:
raise RuntimeError("HEIC image captioning requires pillow-heif; install reme-ai[image-heif]") from exc
try:
register_heif_opener()
except Exception as exc: # pylint: disable=broad-except
raise RuntimeError(f"Failed to initialize HEIC image support: {exc}") from exc
return Image
return Image, ImageOps
def _normalize_image_bytes(data: bytes, suffix: str) -> tuple[bytes, str] | None:
"""Downscale or re-encode image bytes in memory for a VLM request.
def _looks_like_heif(data: bytes) -> bool:
"""Return whether an ISO-BMFF header declares a HEIC/HEIF brand."""
if len(data) < 12 or data[4:8] != b"ftyp":
return False
box_size = int.from_bytes(data[:4], "big")
if box_size < 12:
return False
end = min(box_size, len(data), _MAX_FTYP_SCAN_BYTES)
if data[8:12] in _HEIF_BRANDS:
return True
return any(data[index : index + 4] in _HEIF_BRANDS for index in range(16, end - 3, 4))
Returns ``None`` only when valid image bytes already have a suitable size
and format. Missing dependencies and decode/convert failures are explicit.
The stored resource file is never modified.
"""
image_module = _load_pillow(suffix)
def _register_heif_opener() -> None:
"""Load and register HEIC support only for bytes that declare HEIF."""
try:
image = image_module.open(io.BytesIO(data))
from pillow_heif import register_heif_opener # pylint: disable=import-outside-toplevel
except ImportError as exc:
raise RuntimeError("HEIC image captioning requires pillow-heif; install reme-ai[image-heif]") from exc
try:
register_heif_opener()
except Exception as exc: # pylint: disable=broad-except
raise RuntimeError(f"Failed to initialize HEIC image support: {exc}") from exc
def _normalize_image_bytes(
data: bytes,
suffix: str,
*,
max_image_pixels: int = DEFAULT_MAX_IMAGE_PIXELS,
) -> tuple[bytes | None, str, str]:
"""Validate and optionally normalize image bytes for a VLM request.
The returned tuple is ``(normalized_bytes, request_mime, source_mime)``.
``normalized_bytes`` is ``None`` only when the original bytes can be sent
unchanged. MIME values come from the decoded image rather than its suffix.
Missing dependencies, unsafe pixel counts, and decode/convert failures are
explicit. The stored resource file is never modified.
"""
try:
pixel_limit = int(max_image_pixels)
except (TypeError, ValueError) as exc:
raise ValueError(f"max_image_pixels must be a positive integer: {max_image_pixels!r}") from exc
if pixel_limit <= 0:
raise ValueError(f"max_image_pixels must be a positive integer: {max_image_pixels!r}")
image_module, image_ops = _load_pillow()
if _looks_like_heif(data):
_register_heif_opener()
try:
with warnings.catch_warnings():
warnings.simplefilter("error", image_module.DecompressionBombWarning)
image = image_module.open(io.BytesIO(data))
except (image_module.DecompressionBombWarning, image_module.DecompressionBombError) as exc:
raise RuntimeError(
f"Image rejected by Pillow decompression-bomb protection ({suffix or 'unknown suffix'})",
) from exc
except Exception as exc: # pylint: disable=broad-except
raise RuntimeError(f"Failed to decode image ({suffix or 'unknown suffix'}): {exc}") from exc
with image:
width, height = image.size
pixel_count = width * height
if pixel_count > pixel_limit:
raise RuntimeError(
f"Image exceeds max_image_pixels before decode: " f"{width}x{height}={pixel_count} > {pixel_limit}",
)
source_mime = str(image.get_format_mimetype() or "").strip().lower()
if not source_mime.startswith("image/"):
raise RuntimeError(
f"Cannot determine decoded image MIME type ({suffix or 'unknown suffix'}, format={image.format!r})",
)
needs_resize = width > MAX_IMAGE_REQUEST_DIMENSION or height > MAX_IMAGE_REQUEST_DIMENSION
if source_mime == "image/jpeg" and needs_resize:
max_dimension = max(width, height)
decoder_size = (
max(1, (width * MAX_IMAGE_REQUEST_DIMENSION + max_dimension - 1) // max_dimension),
max(1, (height * MAX_IMAGE_REQUEST_DIMENSION + max_dimension - 1) // max_dimension),
)
try:
# JPEG supports power-of-two decoder scaling. ``draft`` picks
# the smallest decoded frame that still covers decoder_size,
# reducing peak memory before the final LANCZOS thumbnail.
image.draft(None, decoder_size)
except Exception as exc: # pylint: disable=broad-except
raise RuntimeError(f"Failed to prepare JPEG decoder downsampling ({width}x{height}): {exc}") from exc
try:
image.load()
with warnings.catch_warnings():
warnings.simplefilter("error", image_module.DecompressionBombWarning)
image.load()
orientation = int(image.getexif().get(274, 1) or 1)
image_ops.exif_transpose(image, in_place=True)
except (image_module.DecompressionBombWarning, image_module.DecompressionBombError) as exc:
raise RuntimeError(
f"Image rejected by Pillow decompression-bomb protection ({suffix or 'unknown suffix'})",
) from exc
except Exception as exc: # pylint: disable=broad-except
raise RuntimeError(f"Failed to decode image ({suffix or 'unknown suffix'}): {exc}") from exc
needs_resize = image.width > MAX_IMAGE_REQUEST_DIMENSION or image.height > MAX_IMAGE_REQUEST_DIMENSION
needs_convert = suffix in _CONVERT_SUFFIXES
if not needs_resize and not needs_convert:
return None
needs_convert = source_mime not in _PASSTHROUGH_IMAGE_MIMES
needs_orientation = orientation in range(2, 9)
if not needs_resize and not needs_convert and not needs_orientation:
return None, source_mime, source_mime
try:
if needs_resize:
# Resize the decoded source before color conversion so large
# non-JPEG images do not require a second full-size frame.
# Pillow 10 cannot apply LANCZOS directly to 16-bit integer
# modes; NEAREST keeps that path bounded without a full-size
# RGB conversion first.
resize_filter = image_module.Resampling.LANCZOS
if image.mode.startswith("I;16"):
resize_filter = image_module.Resampling.NEAREST
image.thumbnail((MAX_IMAGE_REQUEST_DIMENSION, MAX_IMAGE_REQUEST_DIMENSION), resize_filter)
has_alpha = image.mode in ("RGBA", "LA", "P")
frame = image.convert("RGBA" if has_alpha else "RGB")
if needs_resize:
frame.thumbnail((MAX_IMAGE_REQUEST_DIMENSION, MAX_IMAGE_REQUEST_DIMENSION), image_module.LANCZOS)
buffer = io.BytesIO()
if frame.mode == "RGBA":
frame.save(buffer, format="PNG")
return buffer.getvalue(), "image/png"
frame.save(buffer, format="JPEG", quality=_JPEG_QUALITY)
return buffer.getvalue(), "image/jpeg"
try:
buffer = io.BytesIO()
if frame.mode == "RGBA":
frame.save(buffer, format="PNG")
return buffer.getvalue(), "image/png", source_mime
frame.save(buffer, format="JPEG", quality=_JPEG_QUALITY)
return buffer.getvalue(), "image/jpeg", source_mime
finally:
frame.close()
except Exception as exc: # pylint: disable=broad-except
raise RuntimeError(f"Failed to convert/resize image ({suffix or 'unknown suffix'}): {exc}") from exc
def _build_image_request_payload(data: bytes, suffix: str) -> dict:
def _build_image_request_payload(
data: bytes,
suffix: str,
*,
max_image_pixels: int = DEFAULT_MAX_IMAGE_PIXELS,
) -> dict:
"""Return ``{"data_b64", "mime", "source_mime", "converted"}`` for a VLM request.
``mime`` is the format actually sent (after in-memory downscale/re-encode);
``source_mime`` describes the stored resource file and is what notes record.
``source_mime`` is the decoded format of the stored resource file and is
what notes record. Both are based on actual bytes, not the filename suffix.
"""
source_mime = IMAGE_MIME_BY_EXT.get(suffix, "image/png")
normalized = _normalize_image_bytes(data, suffix)
if normalized is None:
return {
"data_b64": base64.b64encode(data).decode("ascii"),
"mime": source_mime,
"source_mime": source_mime,
"converted": False,
}
normalized_bytes, mime = normalized
normalized_bytes, mime, source_mime = _normalize_image_bytes(
data,
suffix,
max_image_pixels=max_image_pixels,
)
request_bytes = data if normalized_bytes is None else normalized_bytes
return {
"data_b64": base64.b64encode(normalized_bytes).decode("ascii"),
"data_b64": base64.b64encode(request_bytes).decode("ascii"),
"mime": mime,
"source_mime": source_mime,
"converted": normalized_bytes != data,
"converted": normalized_bytes is not None,
}
@ -193,7 +285,9 @@ class AutoImageResourceStep(BaseAutoResourceStep):
"""
resource_suffixes = IMAGE_SUFFIXES
router_inherit_keys = BaseAutoResourceStep.router_inherit_keys | frozenset({"as_llm", "max_image_bytes"})
router_inherit_keys = BaseAutoResourceStep.router_inherit_keys | frozenset(
{"as_llm", "max_image_bytes", "max_image_pixels"},
)
def _max_image_bytes(self) -> int:
"""Return the image read limit from Step or Job context."""
@ -202,6 +296,17 @@ class AutoImageResourceStep(BaseAutoResourceStep):
value = self.context.get("max_image_bytes")
return int(value) if value is not None else DEFAULT_MAX_IMAGE_INPUT_BYTES
def _max_image_pixels(self) -> int:
"""Return the deployment-controlled pre-decode pixel limit."""
value = self.kwargs.get("max_image_pixels", DEFAULT_MAX_IMAGE_PIXELS)
try:
limit = int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"max_image_pixels must be a positive integer: {value!r}") from exc
if limit <= 0:
raise ValueError(f"max_image_pixels must be a positive integer: {value!r}")
return limit
def _vision_model(self) -> ChatModelBase | None:
"""Resolve explicit ``as_llm`` through Ref, otherwise prefer vision/default."""
context_model = self.context.get("as_llm") if self.context is not None else None
@ -266,37 +371,49 @@ class AutoImageResourceStep(BaseAutoResourceStep):
self.logger.warning(f"[{self.name}] resource stat failed file_path={file_path} error={exc}")
return None
if size_bytes > max_image_bytes:
self.context.response.success = True
self.context.response.answer = (
f"Skipped oversized image resource file: {file_path} ({size_bytes} > {max_image_bytes} bytes)"
)
self.context.response.metadata.update(
{
"path": file_path,
"action": "skipped",
"reason": "file_too_large",
"oversized": True,
"size_bytes": size_bytes,
"max_image_bytes": max_image_bytes,
"modified": False,
},
)
self.logger.warning(
f"[{self.name}] skip oversized image resource file_path={file_path} "
f"size_bytes={size_bytes} max_image_bytes={max_image_bytes}",
)
self._record_oversized_image(file_path, size_bytes, max_image_bytes)
return None
self.logger.info(f"[{self.name}] read image start file_path={file_path}")
async with aiofiles.open(source_path, "rb") as f:
data = await f.read()
payload = _build_image_request_payload(data, Path(file_path).suffix.lower())
data = await f.read(max_image_bytes + 1)
if len(data) > max_image_bytes:
self._record_oversized_image(file_path, len(data), max_image_bytes)
return None
payload = _build_image_request_payload(
data,
Path(file_path).suffix.lower(),
max_image_pixels=self._max_image_pixels(),
)
self.logger.info(
f"[{self.name}] read image done file_path={file_path} size_bytes={size_bytes} "
f"mime={payload['mime']} converted={payload['converted']}",
f"mime={payload['mime']} source_mime={payload['source_mime']} converted={payload['converted']}",
)
return payload
def _record_oversized_image(self, file_path: str, size_bytes: int, max_image_bytes: int) -> None:
"""Record a stable skip response for an image over the compressed-byte limit."""
assert self.context is not None
self.context.response.success = True
self.context.response.answer = (
f"Skipped oversized image resource file: {file_path} ({size_bytes} > {max_image_bytes} bytes)"
)
self.context.response.metadata.update(
{
"path": file_path,
"action": "skipped",
"reason": "file_too_large",
"oversized": True,
"size_bytes": size_bytes,
"max_image_bytes": max_image_bytes,
"modified": False,
},
)
self.logger.warning(
f"[{self.name}] skip oversized image resource file_path={file_path} "
f"size_bytes={size_bytes} max_image_bytes={max_image_bytes}",
)
async def _handle_upsert(
self,
file_path: str,

View file

@ -236,6 +236,33 @@ class BaseAutoResourceStep(BaseStep):
notes = list_response.metadata.get("notes") or []
return self._find_resource_note(notes, file_path)
def _daily_note_days(self) -> list[str]:
"""Return safe, deterministic daily subdirectories available for lookup."""
workspace = self.workspace_path.resolve()
daily_dir = str(self.config_value("daily_dir"))
daily_root, error = resolve_path(workspace, daily_dir)
if error or daily_root is None:
raise ValueError(f"invalid daily_dir {daily_dir!r}: {error or 'cannot resolve path'}")
if not daily_root.is_dir():
return []
return sorted(
entry.name
for entry in daily_root.iterdir()
if _DATE_RE.fullmatch(entry.name) and entry.is_dir() and not entry.is_symlink()
)
async def _find_loose_resource_day(self, file_path: str) -> str | None:
"""Find the single daily-card owner for a root-level resource."""
matches: list[tuple[str, str]] = []
for day in self._daily_note_days():
note = await self._list_resource_note(day, file_path)
if note is not None:
matches.append((day, str(note["path"])))
if len(matches) > 1:
paths = ", ".join(path for _, path in matches)
raise RuntimeError(f"Multiple daily resource notes claim {file_path}: {paths}")
return matches[0][0] if matches else None
async def _prepare_resource_note(self, day: str, file_path: str, note_stem: str) -> _ResourceNoteState:
"""Find the owned note or allocate a safe path before the first write."""
note = await self._list_resource_note(day, file_path)
@ -492,8 +519,11 @@ class BaseAutoResourceStep(BaseStep):
loose_filename = _loose_resource_filename(file_path, resource_dir)
if loose_filename:
date_str, filename = self._today(), loose_filename
self.logger.info(f"[{self.name}] loose resource file_path={file_path} date={date_str}")
existing_day = await self._find_loose_resource_day(file_path)
date_str, filename = existing_day or self._today(), loose_filename
self.logger.info(
f"[{self.name}] loose resource file_path={file_path} date={date_str} existing={bool(existing_day)}",
)
else:
date_str, filename = _parse_resource_path(file_path, resource_dir)

View file

@ -210,13 +210,14 @@ def caption_json(name: str, description: str, caption: str) -> str:
return json.dumps({"name": name, "description": description, "caption": caption})
def image_processor(app_context, file_store, model, *, routed: bool):
def image_processor(app_context, file_store, model, *, routed: bool, **kwargs):
"""Build either the image processor or the public unified-router path."""
if not routed:
return AutoImageResourceStep(app_context=app_context, file_store=file_store, as_llm=model)
return AutoImageResourceStep(app_context=app_context, file_store=file_store, as_llm=model, **kwargs)
app_context.registry = R
return AutoResourceStep(
app_context=app_context,
**kwargs,
dispatch_steps=[
{"backend": "auto_image_resource_step", "file_store": file_store, "as_llm": model},
{
@ -244,9 +245,9 @@ class AutoResourceTestEnv:
"""Write a source-owned note relative to this workspace."""
return write_note(self.workspace / relative_path, source_resource, body)
def processor(self, model, *, routed: bool = False):
def processor(self, model, *, routed: bool = False, **kwargs):
"""Build the direct processor or unified router for this workspace."""
return image_processor(self.app_context, self.file_store, model, routed=routed)
return image_processor(self.app_context, self.file_store, model, routed=routed, **kwargs)
async def run(self, step, changes, **context_kwargs):
"""Run one processor invocation with a fresh runtime context."""

View file

@ -9,9 +9,11 @@ with PIL inside a temporary workspace.
import base64
import hashlib
import io
import struct
import subprocess
import sys
import tomllib
import zlib
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
@ -19,7 +21,7 @@ from unittest.mock import patch
import frontmatter
import pytest
import yaml
from PIL import Image
from PIL import Image, JpegImagePlugin
from reme.components import R
from reme.components.component_registry import ComponentRegistry
@ -28,6 +30,7 @@ from reme.components.runtime_context import RuntimeContext
from reme.enumeration import ComponentEnum
from reme.steps.evolve.auto_image_resource import (
AutoImageResourceStep,
DEFAULT_MAX_IMAGE_PIXELS,
_build_image_request_payload,
_normalize_image_bytes,
_parse_caption_json,
@ -52,6 +55,14 @@ from .auto_resource_test_support import (
pytest_plugins = ("unit.auto_resource_test_plugin",)
def _png_bytes_with_header_size(width: int, height: int) -> bytes:
"""Change only a tiny PNG's IHDR dimensions without allocating its pixels."""
data = bytearray(_png_bytes())
data[16:24] = struct.pack(">II", width, height)
data[29:33] = struct.pack(">I", zlib.crc32(data[12:29]) & 0xFFFFFFFF)
return bytes(data)
@pytest.mark.parametrize(
("image_format", "suffix", "source_mime", "request_mime"),
[
@ -178,6 +189,197 @@ async def test_auto_image_downscales_oversized_image_for_request_only(auto_resou
assert (env.workspace / "daily/2026-01-01/huge-image.md").is_file()
@pytest.mark.asyncio
async def test_auto_image_uses_jpeg_decoder_downsampling_before_load(auto_resource_env):
"""Large JPEGs lower their decoder allocation before full pixel decode."""
env = auto_resource_env
source = env.write_binary("resource/2026-01-01/large.jpg", _img_bytes("JPEG", (4096, 2048)))
stored_bytes = source.read_bytes()
model = _FakeVisionModel(_caption_json("large-jpeg", "Large", "A large JPEG."))
decoded_sizes = []
original_load = JpegImagePlugin.JpegImageFile.load
def record_decoder_size(image, *args, **kwargs):
decoded_sizes.append(image.size)
return original_load(image, *args, **kwargs)
with patch.object(JpegImagePlugin.JpegImageFile, "load", new=record_decoder_size):
response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}])
assert response.success is True
assert decoded_sizes
assert decoded_sizes[0] == (2048, 1024)
data_block = model.calls[0][0].content[1]
with Image.open(io.BytesIO(base64.b64decode(data_block.source.data))) as sent:
assert max(sent.size) <= 2048
assert source.read_bytes() == stored_bytes
def test_image_preprocessing_resizes_16_bit_tiff_before_rgb_conversion():
"""Pillow 10-compatible scaling keeps large I;16 TIFF images memory-bounded."""
image = Image.new("I;16", (2049, 2), 1000)
buffer = io.BytesIO()
image.save(buffer, format="TIFF")
calls = []
original_thumbnail = Image.Image.thumbnail
def record_thumbnail(frame, size, resample, *args, **kwargs):
calls.append((frame.mode, resample))
return original_thumbnail(frame, size, resample, *args, **kwargs)
with patch.object(Image.Image, "thumbnail", new=record_thumbnail):
payload = _build_image_request_payload(buffer.getvalue(), ".tiff")
assert calls == [("I;16", Image.Resampling.NEAREST)]
assert payload["mime"] == "image/jpeg"
assert payload["source_mime"] == "image/tiff"
with Image.open(io.BytesIO(base64.b64decode(payload["data_b64"]))) as sent:
assert max(sent.size) <= 2048
@pytest.mark.asyncio
async def test_auto_image_applies_exif_orientation_before_resizing(auto_resource_env):
"""A rotated phone JPEG is normalized upright for the VLM without touching its source."""
env = auto_resource_env
image = Image.new("RGB", (3000, 1000), (40, 80, 120))
exif = Image.Exif()
exif[274] = 6
buffer = io.BytesIO()
image.save(buffer, format="JPEG", exif=exif)
source = env.write_binary("resource/2026-01-01/phone.jpg", buffer.getvalue())
stored_bytes = source.read_bytes()
model = _FakeVisionModel(_caption_json("upright-phone-photo", "Upright", "An upright phone photo."))
response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}])
assert response.success is True
data_block = model.calls[0][0].content[1]
assert data_block.source.media_type == "image/jpeg"
with Image.open(io.BytesIO(base64.b64decode(data_block.source.data))) as sent:
assert sent.size == (683, 2048)
assert sent.getexif().get(274) is None
assert source.read_bytes() == stored_bytes
assert (env.workspace / "daily/2026-01-01/upright-phone-photo.md").is_file()
@pytest.mark.parametrize(
("image_format", "suffix", "source_mime", "request_mime"),
[
("JPEG", ".png", "image/jpeg", "image/jpeg"),
("PNG", ".jpg", "image/png", "image/png"),
("BMP", ".png", "image/bmp", "image/jpeg"),
],
)
@pytest.mark.asyncio
async def test_auto_image_uses_decoded_format_when_suffix_is_misleading(
image_format,
suffix,
source_mime,
request_mime,
auto_resource_env,
):
"""Request and note MIME values come from decoded bytes, with conversion when needed."""
env = auto_resource_env
source = env.write_binary(f"resource/2026-01-01/mislabeled{suffix}", _img_bytes(image_format))
stored_bytes = source.read_bytes()
model = _StructuredVisionModel(
content={"name": "actual-format", "description": "Decoded", "caption": "Decoded image content."},
)
response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}])
assert response.success is True
data_block = model.structured_calls[0][0].content[1]
assert data_block.source.media_type == request_mime
with Image.open(io.BytesIO(base64.b64decode(data_block.source.data))) as sent:
assert sent.get_format_mimetype() == request_mime
note = frontmatter.load(env.workspace / "daily/2026-01-01/actual-format.md")
assert note.metadata["media_type"] == source_mime
assert source.read_bytes() == stored_bytes
@pytest.mark.parametrize("routed", [False, True], ids=["image", "unified-router"])
@pytest.mark.asyncio
async def test_auto_image_rejects_pixel_bomb_before_decode_and_isolates_batch(routed, auto_resource_env):
"""A small compressed file with unsafe dimensions fails without stopping the next image."""
env = auto_resource_env
unsafe = env.write_binary(
"resource/2026-01-01/pixel-bomb.png",
_png_bytes_with_header_size(8000, 6000),
)
safe = env.write_binary("resource/2026-01-01/safe.png", _png_bytes())
model = _FakeVisionModel(_caption_json("safe-image", "Safe", "A safe image."))
response = await env.run(
env.processor(model, routed=routed),
[
{"change": "added", "path": str(unsafe)},
{"change": "added", "path": str(safe)},
],
)
results = response.metadata["results"]
assert response.success is False
assert [item["success"] for item in results] == [False, True]
assert results[0]["metadata"]["action"] == "failed"
assert results[0]["metadata"]["modified"] is False
assert f"48000000 > {DEFAULT_MAX_IMAGE_PIXELS}" in results[0]["metadata"]["error"]
assert len(model.calls) == 1
assert not (env.workspace / "daily/2026-01-01/pixel-bomb.md").exists()
assert (env.workspace / "daily/2026-01-01/safe-image.md").is_file()
def test_image_pixel_limit_is_checked_before_full_decode():
"""The explicit pixel budget is enforced from image headers before ``load``."""
with patch("PIL.PngImagePlugin.PngImageFile.load", side_effect=AssertionError("must not decode")) as image_load:
with pytest.raises(RuntimeError, match=r"8x8=64 > 63"):
_normalize_image_bytes(_png_bytes(), ".png", max_image_pixels=63)
image_load.assert_not_called()
def test_image_decompression_bomb_warning_becomes_an_error(monkeypatch):
"""Pillow's warning-only bomb threshold becomes a reportable processor error."""
monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 32)
with pytest.raises(RuntimeError, match="decompression-bomb protection"):
_build_image_request_payload(_png_bytes(), ".png")
@pytest.mark.parametrize("routed", [False, True], ids=["image", "unified-router"])
@pytest.mark.asyncio
async def test_decompression_bomb_warning_is_isolated_per_change(routed, auto_resource_env, monkeypatch):
"""A Pillow bomb warning fails one resource while a safe batch peer still completes."""
env = auto_resource_env
warned = env.write_binary("resource/2026-01-01/warned.png", _png_bytes())
safe = env.write_binary("resource/2026-01-01/safe.png", _png_bytes(width=4, height=4))
model = _FakeVisionModel(_caption_json("safe-image", "Safe", "A safe image."))
monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 32)
response = await env.run(
env.processor(model, routed=routed),
[
{"change": "added", "path": str(warned)},
{"change": "added", "path": str(safe)},
],
)
results = response.metadata["results"]
assert response.success is False
assert [item["success"] for item in results] == [False, True]
assert "decompression-bomb protection" in results[0]["metadata"]["error"]
assert len(model.calls) == 1
assert not (env.workspace / "daily/2026-01-01/warned.md").exists()
assert (env.workspace / "daily/2026-01-01/safe-image.md").is_file()
def test_auto_image_pixel_limit_cannot_be_raised_by_runtime_context():
"""Request-scoped kwargs cannot relax the processor's deployment safety cap."""
step = AutoImageResourceStep(max_image_pixels=63)
step.context = RuntimeContext(max_image_pixels=DEFAULT_MAX_IMAGE_PIXELS)
assert step._max_image_pixels() == 63
@pytest.mark.asyncio
async def test_auto_image_skips_oversized_file(auto_resource_env):
"""Files beyond max_image_bytes are skipped without a VLM call."""
@ -334,8 +536,9 @@ def test_auto_resource_router_inherits_declared_options_with_child_override():
prompt_dict=prompt_dict,
max_file_bytes=4,
max_image_bytes=8,
max_image_pixels=64,
dispatch_steps=[
{"backend": "auto_image_resource_step", "max_image_bytes": 32},
{"backend": "auto_image_resource_step", "max_image_bytes": 32, "max_image_pixels": 128},
{"backend": "auto_text_resource_step", "max_file_bytes": 16},
],
)
@ -355,8 +558,16 @@ def test_auto_resource_router_inherits_declared_options_with_child_override():
"as_llm": vision_model,
"language": "zh",
"max_image_bytes": 32,
"max_image_pixels": 128,
}
inherited = AutoResourceStep(
max_image_pixels=64,
dispatch_steps=["auto_image_resource_step", "auto_text_resource_step"],
)
inherited_specs = {spec["backend"]: spec for spec, _, _ in inherited._processor_routes()}
assert inherited_specs["auto_image_resource_step"]["max_image_pixels"] == 64
@pytest.mark.asyncio
async def test_auto_resource_router_accepts_a_registered_third_modality_without_code_changes(auto_resource_env):
@ -511,13 +722,18 @@ def test_auto_image_named_model_uses_standard_ref_resolution():
@pytest.mark.parametrize(
("blocked_module", "suffix", "error_pattern"),
("blocked_module", "payload", "suffix", "error_pattern"),
[
("PIL", ".png", r"Pillow.*reme-ai\[core\]"),
("pillow_heif", ".heic", r"pillow-heif.*reme-ai\[image-heif\]"),
("PIL", _png_bytes(), ".png", r"Pillow.*reme-ai\[core\]"),
(
"pillow_heif",
b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00mif1heic",
".heic",
r"pillow-heif.*reme-ai\[image-heif\]",
),
],
)
def test_image_preprocessing_reports_dependency_errors(blocked_module, suffix, error_pattern):
def test_image_preprocessing_reports_dependency_errors(blocked_module, payload, suffix, error_pattern):
"""Lazy image dependencies produce actionable installation errors."""
real_import = __import__
@ -528,7 +744,24 @@ def test_image_preprocessing_reports_dependency_errors(blocked_module, suffix, e
with patch("builtins.__import__", side_effect=import_without_dependency):
with pytest.raises(RuntimeError, match=error_pattern):
_normalize_image_bytes(_png_bytes(), suffix)
_normalize_image_bytes(payload, suffix)
def test_misleading_heic_suffix_does_not_load_optional_dependency():
"""A core image named ``.heic`` is decoded by content without loading pillow-heif."""
real_import = __import__
def import_without_heif(name, *args, **kwargs):
if name == "pillow_heif":
raise AssertionError("pillow-heif must not be loaded for PNG bytes")
return real_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=import_without_heif):
payload = _build_image_request_payload(_png_bytes(), ".heic")
assert payload["mime"] == "image/png"
assert payload["source_mime"] == "image/png"
assert payload["converted"] is False
@pytest.mark.parametrize(

View file

@ -1,8 +1,12 @@
"""Regression tests for the safety findings from the Auto Resource PR review."""
from unittest.mock import patch
import frontmatter
import pytest
from reme.steps.evolve.base_auto_resource import BaseAutoResourceStep
from .auto_resource_test_support import (
FakeVisionModel,
StructuredVisionModel,
@ -167,3 +171,120 @@ async def test_blank_plain_caption_does_not_create_or_overwrite_note(routed, pla
assert not (env.workspace / "daily/2026-01-01/blank-new.md").exists()
assert old_note.read_bytes() == before
assert len(model.structured_calls) == len(model.plain_calls) == 2
@pytest.mark.parametrize("routed", [False, True], ids=["image", "unified-router"])
async def test_loose_root_image_keeps_original_daily_card_across_days(routed, auto_resource_env):
"""Later updates and deletion keep a loose resource's first daily-card ownership."""
env = auto_resource_env
source = env.write_binary("resource/photo.png", image_bytes(color=(200, 30, 30)))
initial_model = FakeVisionModel(caption_json("original-card", "Original", "first-day caption"))
with patch.object(BaseAutoResourceStep, "_today", return_value="2026-01-01"):
added = await env.run(
env.processor(initial_model, routed=routed),
[{"change": "added", "path": str(source)}],
)
owned_note = env.workspace / "daily/2026-01-01/original-card.md"
add_result = added.metadata["results"][0]["metadata"]
assert added.success is True
assert add_result["path"] == "daily/2026-01-01/original-card.md"
assert add_result["action"] == "added"
assert add_result["index"]["date"] == "2026-01-01"
assert "first-day caption" in owned_note.read_text(encoding="utf-8")
unrelated_note = env.write_note(
"daily/2026-01-02/photo.md",
"[[resource/other.png]]",
body="unrelated note that must survive",
)
unrelated_before = unrelated_note.read_bytes()
first_model = FakeVisionModel(caption_json("renamed-on-day-two", "Updated", "second-day caption"))
with patch.object(BaseAutoResourceStep, "_today", return_value="2026-01-02"):
first_update = await env.run(
env.processor(first_model, routed=routed),
[{"change": "modified", "path": str(source)}],
)
first_result = first_update.metadata["results"][0]["metadata"]
assert first_update.success is True
assert first_result["path"] == "daily/2026-01-01/original-card.md"
assert first_result["action"] == "modified"
assert first_result["created"] is False
assert first_result["index"]["date"] == "2026-01-01"
assert "second-day caption" in owned_note.read_text(encoding="utf-8")
assert not (env.workspace / "daily/2026-01-02/renamed-on-day-two.md").exists()
assert unrelated_note.read_bytes() == unrelated_before
assert not (env.workspace / "daily/2026-01-02.md").exists()
source.write_bytes(image_bytes(color=(20, 90, 200)))
second_model = FakeVisionModel(caption_json("renamed-on-day-three", "Updated again", "third-day caption"))
with patch.object(BaseAutoResourceStep, "_today", return_value="2026-01-03"):
second_update = await env.run(
env.processor(second_model, routed=routed),
[{"change": "modified", "path": str(source)}],
)
second_result = second_update.metadata["results"][0]["metadata"]
assert second_update.success is True
assert second_result["path"] == "daily/2026-01-01/original-card.md"
assert second_result["action"] == "modified"
assert second_result["created"] is False
assert second_result["index"]["date"] == "2026-01-01"
assert "third-day caption" in owned_note.read_text(encoding="utf-8")
assert not (env.workspace / "daily/2026-01-03/renamed-on-day-three.md").exists()
assert unrelated_note.read_bytes() == unrelated_before
assert not (env.workspace / "daily/2026-01-03.md").exists()
source.unlink()
delete_model = FakeVisionModel(caption_json("unused", "Unused", "Must not be requested."))
with patch.object(BaseAutoResourceStep, "_today", return_value="2026-01-04"):
deleted = await env.run(
env.processor(delete_model, routed=routed),
[{"change": "deleted", "path": str(source)}],
)
delete_result = deleted.metadata["results"][0]["metadata"]
assert deleted.success is True
assert delete_result["path"] == "daily/2026-01-01/original-card.md"
assert delete_result["action"] == "deleted"
assert delete_result["modified"] is True
assert delete_result["index"]["date"] == "2026-01-01"
assert delete_result["index"]["notes"] == []
assert not owned_note.exists()
assert unrelated_note.read_bytes() == unrelated_before
assert "(none)" in (env.workspace / "daily/2026-01-01.md").read_text(encoding="utf-8")
assert not (env.workspace / "daily/2026-01-04.md").exists()
assert len(initial_model.calls) == len(first_model.calls) == len(second_model.calls) == 1
assert not delete_model.calls
@pytest.mark.parametrize("routed", [False, True], ids=["image", "unified-router"])
async def test_loose_root_image_duplicate_daily_owners_fail_closed(routed, auto_resource_env):
"""Ambiguous exact ownership is reported without reading the image model or changing notes."""
env = auto_resource_env
source = env.write_binary("resource/duplicate.png", image_bytes())
first_note = env.write_note("daily/2026-01-01/first.md", "[[resource/duplicate.png]]", body="first owner")
second_note = env.write_note("daily/2026-01-02/second.md", "[[resource/duplicate.png]]", body="second owner")
before = {first_note: first_note.read_bytes(), second_note: second_note.read_bytes()}
model = FakeVisionModel(caption_json("replacement", "Replacement", "Must not be generated."))
with patch.object(BaseAutoResourceStep, "_today", return_value="2026-01-03"):
response = await env.run(
env.processor(model, routed=routed),
[{"change": "modified", "path": str(source)}],
)
result = response.metadata["results"][0]["metadata"]
assert response.success is False
assert result["action"] == "failed"
assert result["modified"] is False
assert "Multiple daily resource notes claim resource/duplicate.png" in result["error"]
assert "daily/2026-01-01/first.md" in result["error"]
assert "daily/2026-01-02/second.md" in result["error"]
assert not model.calls
assert all(path.read_bytes() == contents for path, contents in before.items())
assert not (env.workspace / "daily/2026-01-01.md").exists()
assert not (env.workspace / "daily/2026-01-02.md").exists()
assert not (env.workspace / "daily/2026-01-03.md").exists()