mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-15 09:11:24 +00:00
refactor(auto-memory): keep bound model and native image helpers
This commit is contained in:
parent
c92543a560
commit
a681dc2368
11 changed files with 379 additions and 744 deletions
|
|
@ -89,7 +89,8 @@ reme auto_memory session_id=session-a include_images=true supports_vision=true m
|
|||
|
||||
For a one-shot CLI invocation, use `reme start job=auto_memory` with the same arguments.
|
||||
No YAML edit or rebuild is needed. `include_images` controls whether Auto Memory considers images;
|
||||
`supports_vision` is the caller's declaration about the selected model, not automatic capability detection.
|
||||
`supports_vision` declares whether the AgentScope wrapper's bound `as_llm` supports images; it does not detect capabilities
|
||||
or select a different model.
|
||||
Only `include_images=true supports_vision=true` sends images. Choose a model and AgentScope formatter that support them.
|
||||
|
||||
`include_images=false` keeps the existing text-only behavior on every wrapper, regardless of `supports_vision`.
|
||||
|
|
@ -119,9 +120,9 @@ When no image-count limit is explicitly configured, direct mode reserves room fo
|
|||
context. An explicit `context_config.max_image_num` below the incoming count causes a visible whole-input text fallback,
|
||||
not silent removal of older images. Model/provider context limits still apply.
|
||||
|
||||
When sending images, Auto Memory uses `components.as_llm.vision` if configured, otherwise the wrapper's currently
|
||||
bound `as_llm`. `supports_vision=true` must describe that selected model. This selection applies only to the current
|
||||
memory Agent invocation; calls without image inputs keep the wrapper's original model.
|
||||
Text-only and image-bearing Auto Memory calls use the same model bound through the AgentScope wrapper's `as_llm`.
|
||||
`supports_vision=true` must describe that model. The optional `components.as_llm.vision` used for image resources
|
||||
does not change Auto Memory's model.
|
||||
|
||||
Auto Memory does not create resource image files or separate caption cards. Relevant image facts may become part of
|
||||
the normal daily memory note. The existing AgentScope wrapper also saves its internal Agent state under
|
||||
|
|
|
|||
|
|
@ -81,8 +81,9 @@ reme auto_memory session_id=session-a include_images=true supports_vision=true m
|
|||
```
|
||||
|
||||
单次 CLI 调用可使用 `reme start job=auto_memory`,其余参数相同。
|
||||
不需要修改 YAML 或重新编译。`include_images` 控制是否考虑图像;`supports_vision` 是调用方对所选模型的视觉能力声明,
|
||||
不是自动探测。仅当 `include_images=true supports_vision=true` 时才发送图像;请选用支持这些图像的模型与 AgentScope formatter。
|
||||
不需要修改 YAML 或重新编译。`include_images` 控制是否考虑图像;`supports_vision` 声明 AgentScope wrapper 绑定的 `as_llm`
|
||||
是否支持图像,不会自动探测能力或切换模型。仅当 `include_images=true supports_vision=true` 时才发送图像;
|
||||
请选用支持这些图像的模型与 AgentScope formatter。
|
||||
|
||||
`include_images=false` 时,无论 `supports_vision` 如何设置,各种 wrapper 都保持原有纯文本行为。
|
||||
纯文本仍通过已有的 `agent_wrapper.reply()` 字符串输入路径执行,无需为其他 wrapper 改造多模态接口。
|
||||
|
|
@ -109,9 +110,9 @@ reme auto_memory session_id=session-a include_images=true supports_vision=true m
|
|||
若显式设置的 `context_config.max_image_num` 小于输入图像数,会整次显式回退为纯文本,不静默移除较早图像。
|
||||
模型和 provider 自身的上下文限制仍然适用。
|
||||
|
||||
发送图像时优先使用已配置的 `components.as_llm.vision`,未配置时使用 wrapper 当前绑定的 `as_llm`。
|
||||
`supports_vision=true` 应当对应这个实际选中的模型。模型选择仅用于当前这次记忆 Agent 调用;
|
||||
没有图像输入的调用仍使用 wrapper 原有模型。
|
||||
纯文本与图文 Auto Memory 均使用 AgentScope wrapper 通过 `as_llm` 绑定的同一个模型。
|
||||
`supports_vision=true` 应当描述这个模型的能力。图像资源处理使用的可选 `components.as_llm.vision`
|
||||
不会改变 Auto Memory 的模型。
|
||||
|
||||
Auto Memory 不创建 resource 图像文件或独立 caption 卡片;
|
||||
相关图像事实仍可被提取进普通 daily 记忆笔记。AgentScope wrapper 仍按原有行为在 `mem_session/agentscope` 保存
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ from agentscope.event import (
|
|||
ToolResultTextDeltaEvent,
|
||||
)
|
||||
from agentscope.message import TextBlock, ToolResultState, UserMsg
|
||||
from agentscope.model import ChatModelBase
|
||||
from agentscope.permission import PermissionBehavior, PermissionContext, PermissionDecision, PermissionMode
|
||||
from agentscope.state import AgentState
|
||||
from agentscope.tool import (
|
||||
|
|
@ -300,20 +299,11 @@ class AsAgentWrapper(BaseAgentWrapper):
|
|||
"""Resolve configured skill names to AgentScope local skill directories."""
|
||||
return [str(path) for path in self._resolve_project_skills(skills).values()]
|
||||
|
||||
def _resolve_model(self, kwargs: dict[str, Any]) -> ChatModelBase:
|
||||
"""Use an internal, caller-owned model override for this invocation only."""
|
||||
if (model := kwargs.get("_model")) is not None:
|
||||
if not isinstance(model, ChatModelBase):
|
||||
raise TypeError("_model must be an initialized AgentScope ChatModelBase instance.")
|
||||
return model
|
||||
async def _build_agent(self, inputs: Any, **kwargs) -> tuple[Agent, Any]:
|
||||
"""Build an Agent instance from kwargs. Returns (agent, processed_inputs)."""
|
||||
model = self.as_llm.model if self.as_llm else None
|
||||
if model is None:
|
||||
raise ValueError("AsAgentWrapper requires a bound as_llm component with a valid model.")
|
||||
return model
|
||||
|
||||
async def _build_agent(self, inputs: Any, **kwargs) -> tuple[Agent, Any]:
|
||||
"""Build an Agent instance from kwargs. Returns (agent, processed_inputs)."""
|
||||
model = self._resolve_model(kwargs)
|
||||
|
||||
self._cleanup_expired_sessions()
|
||||
|
||||
|
|
@ -373,7 +363,9 @@ class AsAgentWrapper(BaseAgentWrapper):
|
|||
|
||||
output_schema: dict | None = kwargs.get("output_schema")
|
||||
if output_schema is not None:
|
||||
model = self._resolve_model(kwargs)
|
||||
assert self.as_llm is not None, "AsAgentWrapper requires a bound as_llm component with a valid model."
|
||||
model = self.as_llm.model
|
||||
assert model is not None, "AsAgentWrapper requires a bound as_llm component with a valid model."
|
||||
res = await model.generate_structured_output(
|
||||
messages=agent.state.context,
|
||||
structured_model=output_schema,
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ jobs:
|
|||
default: false
|
||||
supports_vision:
|
||||
type: boolean
|
||||
description: "Caller declares that the selected memory model supports images; does not enable images by itself"
|
||||
description: "Caller declares that the AgentScope wrapper's bound as_llm supports images; does not enable images by itself"
|
||||
default: false
|
||||
image_mode:
|
||||
type: string
|
||||
|
|
@ -827,10 +827,9 @@ components:
|
|||
max_tokens: 65536
|
||||
thinking_enable: false
|
||||
|
||||
# Optional vision model for image resources and Auto Memory image inputs.
|
||||
# Direct Auto Memory uses it only for the current image-bearing Agent call;
|
||||
# when absent, it keeps the model already bound to that Agent wrapper.
|
||||
# Image resource/caption processing otherwise falls back to "default".
|
||||
# Optional dedicated vision model for image resources (auto_image_resource_step).
|
||||
# Falls back to the "default" instance above when absent; uncomment to
|
||||
# decouple the vision model from the main LLM.
|
||||
# vision:
|
||||
# backend: ${VLM_BACKEND:-openai}
|
||||
# model: ${VLM_MODEL_NAME:-}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,9 @@ import httpx
|
|||
from agentscope.agent import ContextConfig
|
||||
from agentscope.message import Base64Source, DataBlock, Msg, TextBlock, UserMsg
|
||||
|
||||
from ._image_caption import DEFAULT_MAX_IMAGE_INPUT_BYTES, _build_image_request_payload
|
||||
from .auto_image_resource import DEFAULT_MAX_IMAGE_INPUT_BYTES, _build_image_request_payload
|
||||
from ..file_io._path import _check_path_permission, resolve_path
|
||||
from ...components.agent_wrapper.as_agent_wrapper import AsAgentWrapper
|
||||
from ...enumeration import ComponentEnum
|
||||
|
||||
|
||||
async def prepare_direct_message(
|
||||
|
|
@ -49,15 +48,8 @@ async def prepare_direct_message(
|
|||
f"[{step.name}] supports_vision is false; continuing with text-only memory without reading images.",
|
||||
)
|
||||
return None
|
||||
stage = "model"
|
||||
stage = "context-image-limit"
|
||||
try:
|
||||
component = wrapper.as_llm
|
||||
if step.app_context is not None:
|
||||
component = step.app_context.components.get(ComponentEnum.AS_LLM, {}).get("vision", component)
|
||||
model = component.model if component is not None else None
|
||||
if model is None:
|
||||
raise ValueError("Direct images require a started memory model")
|
||||
stage = "context-image-limit"
|
||||
context_config = dict(
|
||||
(reply_kwargs or {}).get("context_config", wrapper.kwargs.get("context_config")) or {},
|
||||
)
|
||||
|
|
@ -99,8 +91,6 @@ async def prepare_direct_message(
|
|||
return None
|
||||
if reply_kwargs is not None:
|
||||
reply_kwargs["context_config"] = context_config
|
||||
# Override only this Agent invocation; never rebind the shared wrapper.
|
||||
reply_kwargs["_model"] = model
|
||||
metadata["status"] = "prepared"
|
||||
return result
|
||||
|
||||
|
|
|
|||
|
|
@ -1,335 +0,0 @@
|
|||
"""Shared image preprocessing and caption generation, without workspace writes."""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import warnings
|
||||
|
||||
from agentscope.message import Base64Source, DataBlock, TextBlock, UserMsg
|
||||
from agentscope.model import ChatModelBase
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
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
|
||||
# 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)
|
||||
|
||||
|
||||
class _CaptionOutput(BaseModel):
|
||||
"""Structured caption contract enforced on the vision model."""
|
||||
|
||||
name: str = Field(
|
||||
description="short kebab-case topic stem based on visible content; filename is only a weak naming hint",
|
||||
)
|
||||
description: str = Field(description="one-sentence summary of visible image content that stands on its own")
|
||||
caption: str = Field(
|
||||
description="complete description / verbatim transcription of meaningful content visible in the image",
|
||||
)
|
||||
|
||||
|
||||
def _load_pillow():
|
||||
"""Load the core image dependency only when image processing runs."""
|
||||
try:
|
||||
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
|
||||
return Image, ImageOps
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
def _register_heif_opener() -> None:
|
||||
"""Load and register HEIC support only for bytes that declare HEIF."""
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
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_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
|
||||
resize_frame = None
|
||||
try:
|
||||
frame = image
|
||||
if needs_resize:
|
||||
# Pillow forces NEAREST for palette and bilevel images, even
|
||||
# when LANCZOS is requested. Expand these modes within the
|
||||
# checked pixel budget so resizing retains fine strokes and
|
||||
# palette transparency. Other modes resize before conversion.
|
||||
if image.mode in ("P", "1"):
|
||||
resize_frame = image.convert("RGBA" if image.mode == "P" else "L")
|
||||
frame = resize_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 frame.mode.startswith("I;16"):
|
||||
resize_filter = image_module.Resampling.NEAREST
|
||||
frame.thumbnail((MAX_IMAGE_REQUEST_DIMENSION, MAX_IMAGE_REQUEST_DIMENSION), resize_filter)
|
||||
has_alpha = frame.mode in ("RGBA", "LA", "P")
|
||||
frame = frame.convert("RGBA" if has_alpha else "RGB")
|
||||
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
|
||||
finally:
|
||||
if resize_frame is not None:
|
||||
resize_frame.close()
|
||||
|
||||
|
||||
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`` is the decoded format of the stored resource file and is
|
||||
what notes record. Both are based on actual bytes, not the filename suffix.
|
||||
"""
|
||||
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(request_bytes).decode("ascii"),
|
||||
"mime": mime,
|
||||
"source_mime": source_mime,
|
||||
"converted": normalized_bytes is not None,
|
||||
}
|
||||
|
||||
|
||||
async def _response_text(result) -> str:
|
||||
"""Extract text blocks from a streaming or non-streaming ChatResponse."""
|
||||
if hasattr(type(result), "__aiter__"):
|
||||
last = None
|
||||
async for chunk in result:
|
||||
last = chunk
|
||||
result = last
|
||||
if result is None:
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for block in result.content or []:
|
||||
if isinstance(block, dict):
|
||||
if block.get("type") == "text":
|
||||
parts.append(str(block.get("text") or ""))
|
||||
elif getattr(block, "type", None) == "text":
|
||||
parts.append(str(getattr(block, "text", "") or ""))
|
||||
return "".join(parts).strip()
|
||||
|
||||
|
||||
def _normalize_caption_fields(parsed: dict) -> dict:
|
||||
"""Normalize parsed caption fields, cross-filling a missing ``caption``
|
||||
from a present ``description`` so raw JSON never reaches the note body."""
|
||||
caption = str(parsed.get("caption") or "").strip()
|
||||
description = str(parsed.get("description") or "").strip()
|
||||
if not caption and description:
|
||||
caption = description
|
||||
return {
|
||||
"name": str(parsed.get("name") or "").strip(),
|
||||
"description": description,
|
||||
"caption": caption,
|
||||
}
|
||||
|
||||
|
||||
def _parse_caption_json(text: str) -> dict:
|
||||
"""Parse a plain-call caption response leniently.
|
||||
|
||||
Used as the fallback when the schema-forced structured call fails: fenced
|
||||
JSON and embedded ``{...}`` slices are tried before degrading the whole
|
||||
response text to the caption.
|
||||
"""
|
||||
cleaned = text.strip()
|
||||
fence = _JSON_FENCE_RE.match(cleaned)
|
||||
if fence:
|
||||
cleaned = fence.group(1)
|
||||
parsed_json = False
|
||||
for candidate in (cleaned, cleaned[cleaned.find("{") : cleaned.rfind("}") + 1]):
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(candidate)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
parsed_json = True
|
||||
if isinstance(parsed, dict):
|
||||
normalized = _normalize_caption_fields(parsed)
|
||||
if normalized["caption"] or normalized["description"]:
|
||||
return normalized
|
||||
if parsed_json:
|
||||
return {"name": "", "description": "", "caption": ""}
|
||||
return {"name": "", "description": "", "caption": cleaned.strip()}
|
||||
|
||||
|
||||
def resolve_vision_model(step) -> ChatModelBase | None:
|
||||
"""Resolve explicit ``as_llm`` through Ref, otherwise prefer vision/default."""
|
||||
context_model = step.context.get("as_llm") if step.context is not None else None
|
||||
if "as_llm" in step.kwargs or isinstance(context_model, ChatModelBase):
|
||||
return step.as_llm
|
||||
if step.app_context is None:
|
||||
return None
|
||||
models = step.app_context.components.get(ComponentEnum.AS_LLM, {})
|
||||
for name in ("vision", "default"):
|
||||
if name in models:
|
||||
step.kwargs["as_llm"] = name
|
||||
return step.as_llm
|
||||
return None
|
||||
|
||||
|
||||
async def generate_image_caption(
|
||||
model: ChatModelBase,
|
||||
payload: dict,
|
||||
prompt: str,
|
||||
*,
|
||||
logger,
|
||||
name: str,
|
||||
) -> dict:
|
||||
"""Caption a prepared image, preserving the resource model-call contract.
|
||||
|
||||
Try structured output first, then one lenient plain-call fallback. The
|
||||
caller owns model resolution, prompt rendering, and any persistence.
|
||||
"""
|
||||
user_message = UserMsg(
|
||||
name="user",
|
||||
content=[
|
||||
TextBlock(text=prompt),
|
||||
DataBlock(
|
||||
source=Base64Source(data=payload["data_b64"], media_type=payload["mime"]),
|
||||
name="image",
|
||||
),
|
||||
],
|
||||
)
|
||||
try:
|
||||
structured = await model.generate_structured_output(
|
||||
messages=[user_message],
|
||||
structured_model=_CaptionOutput,
|
||||
)
|
||||
content = structured.content if isinstance(structured.content, dict) else {}
|
||||
normalized = _normalize_caption_fields(dict(content))
|
||||
if normalized["caption"] or normalized["description"]:
|
||||
logger.info(f"[{name}] structured caption ok name={normalized['name']}")
|
||||
return normalized
|
||||
logger.warning(f"[{name}] structured caption empty; retrying with a plain call")
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.warning(f"[{name}] structured caption failed ({exc}); retrying with a plain call")
|
||||
result = await model([user_message])
|
||||
parsed = _parse_caption_json(await _response_text(result))
|
||||
if not parsed["caption"] and not parsed["description"]:
|
||||
raise RuntimeError("Vision model returned no usable caption")
|
||||
return parsed
|
||||
|
|
@ -1,20 +1,285 @@
|
|||
"""Image resource processor for the unified auto-resource router."""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import warnings
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
import aiofiles
|
||||
from agentscope.message import Base64Source, DataBlock, TextBlock, UserMsg
|
||||
from agentscope.model import ChatModelBase
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..file_io._path import IMAGE_SUFFIXES
|
||||
from ._image_caption import (
|
||||
DEFAULT_MAX_IMAGE_INPUT_BYTES,
|
||||
DEFAULT_MAX_IMAGE_PIXELS,
|
||||
_build_image_request_payload,
|
||||
generate_image_caption,
|
||||
resolve_vision_model,
|
||||
)
|
||||
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
|
||||
# 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)
|
||||
|
||||
|
||||
class _CaptionOutput(BaseModel):
|
||||
"""Structured caption contract enforced on the vision model."""
|
||||
|
||||
name: str = Field(
|
||||
description="short kebab-case topic stem based on visible content; filename is only a weak naming hint",
|
||||
)
|
||||
description: str = Field(description="one-sentence summary of visible image content that stands on its own")
|
||||
caption: str = Field(
|
||||
description="complete description / verbatim transcription of meaningful content visible in the image",
|
||||
)
|
||||
|
||||
|
||||
def _load_pillow():
|
||||
"""Load the core image dependency only when image processing runs."""
|
||||
try:
|
||||
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
|
||||
return Image, ImageOps
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
def _register_heif_opener() -> None:
|
||||
"""Load and register HEIC support only for bytes that declare HEIF."""
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
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_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
|
||||
resize_frame = None
|
||||
try:
|
||||
frame = image
|
||||
if needs_resize:
|
||||
# Pillow forces NEAREST for palette and bilevel images, even
|
||||
# when LANCZOS is requested. Expand these modes within the
|
||||
# checked pixel budget so resizing retains fine strokes and
|
||||
# palette transparency. Other modes resize before conversion.
|
||||
if image.mode in ("P", "1"):
|
||||
resize_frame = image.convert("RGBA" if image.mode == "P" else "L")
|
||||
frame = resize_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 frame.mode.startswith("I;16"):
|
||||
resize_filter = image_module.Resampling.NEAREST
|
||||
frame.thumbnail((MAX_IMAGE_REQUEST_DIMENSION, MAX_IMAGE_REQUEST_DIMENSION), resize_filter)
|
||||
has_alpha = frame.mode in ("RGBA", "LA", "P")
|
||||
frame = frame.convert("RGBA" if has_alpha else "RGB")
|
||||
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
|
||||
finally:
|
||||
if resize_frame is not None:
|
||||
resize_frame.close()
|
||||
|
||||
|
||||
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`` is the decoded format of the stored resource file and is
|
||||
what notes record. Both are based on actual bytes, not the filename suffix.
|
||||
"""
|
||||
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(request_bytes).decode("ascii"),
|
||||
"mime": mime,
|
||||
"source_mime": source_mime,
|
||||
"converted": normalized_bytes is not None,
|
||||
}
|
||||
|
||||
|
||||
async def _response_text(result) -> str:
|
||||
"""Extract text blocks from a streaming or non-streaming ChatResponse."""
|
||||
if hasattr(type(result), "__aiter__"):
|
||||
last = None
|
||||
async for chunk in result:
|
||||
last = chunk
|
||||
result = last
|
||||
if result is None:
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for block in result.content or []:
|
||||
if isinstance(block, dict):
|
||||
if block.get("type") == "text":
|
||||
parts.append(str(block.get("text") or ""))
|
||||
elif getattr(block, "type", None) == "text":
|
||||
parts.append(str(getattr(block, "text", "") or ""))
|
||||
return "".join(parts).strip()
|
||||
|
||||
|
||||
def _normalize_caption_fields(parsed: dict) -> dict:
|
||||
"""Normalize parsed caption fields, cross-filling a missing ``caption``
|
||||
from a present ``description`` so raw JSON never reaches the note body."""
|
||||
caption = str(parsed.get("caption") or "").strip()
|
||||
description = str(parsed.get("description") or "").strip()
|
||||
if not caption and description:
|
||||
caption = description
|
||||
return {
|
||||
"name": str(parsed.get("name") or "").strip(),
|
||||
"description": description,
|
||||
"caption": caption,
|
||||
}
|
||||
|
||||
|
||||
def _parse_caption_json(text: str) -> dict:
|
||||
"""Parse a plain-call caption response leniently.
|
||||
|
||||
Used as the fallback when the schema-forced structured call fails: fenced
|
||||
JSON and embedded ``{...}`` slices are tried before degrading the whole
|
||||
response text to the caption.
|
||||
"""
|
||||
cleaned = text.strip()
|
||||
fence = _JSON_FENCE_RE.match(cleaned)
|
||||
if fence:
|
||||
cleaned = fence.group(1)
|
||||
parsed_json = False
|
||||
for candidate in (cleaned, cleaned[cleaned.find("{") : cleaned.rfind("}") + 1]):
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(candidate)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
parsed_json = True
|
||||
if isinstance(parsed, dict):
|
||||
normalized = _normalize_caption_fields(parsed)
|
||||
if normalized["caption"] or normalized["description"]:
|
||||
return normalized
|
||||
if parsed_json:
|
||||
return {"name": "", "description": "", "caption": ""}
|
||||
return {"name": "", "description": "", "caption": cleaned.strip()}
|
||||
|
||||
|
||||
@R.register("auto_image_resource_step")
|
||||
|
|
@ -54,7 +319,44 @@ class AutoImageResourceStep(BaseAutoResourceStep):
|
|||
|
||||
def _vision_model(self) -> ChatModelBase | None:
|
||||
"""Resolve explicit ``as_llm`` through Ref, otherwise prefer vision/default."""
|
||||
return resolve_vision_model(self)
|
||||
context_model = self.context.get("as_llm") if self.context is not None else None
|
||||
if "as_llm" in self.kwargs or isinstance(context_model, ChatModelBase):
|
||||
return self.as_llm
|
||||
if self.app_context is None:
|
||||
return None
|
||||
models = self.app_context.components.get(ComponentEnum.AS_LLM, {})
|
||||
for name in ("vision", "default"):
|
||||
if name in models:
|
||||
self.kwargs["as_llm"] = name
|
||||
return self.as_llm
|
||||
return None
|
||||
|
||||
async def _caption_with_retry(self, model: ChatModelBase, user_message: UserMsg) -> dict:
|
||||
"""Return the caption fields from the vision model.
|
||||
|
||||
Primary path is the schema-forced structured output (the SDK enforces
|
||||
the ``name``/``description``/``caption`` contract and retries transport
|
||||
errors). When that fails or yields no usable field, retry once with a
|
||||
plain call parsed leniently.
|
||||
"""
|
||||
try:
|
||||
structured = await model.generate_structured_output(
|
||||
messages=[user_message],
|
||||
structured_model=_CaptionOutput,
|
||||
)
|
||||
content = structured.content if isinstance(structured.content, dict) else {}
|
||||
normalized = _normalize_caption_fields(dict(content))
|
||||
if normalized["caption"] or normalized["description"]:
|
||||
self.logger.info(f"[{self.name}] structured caption ok name={normalized['name']}")
|
||||
return normalized
|
||||
self.logger.warning(f"[{self.name}] structured caption empty; retrying with a plain call")
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self.logger.warning(f"[{self.name}] structured caption failed ({exc}); retrying with a plain call")
|
||||
result = await model([user_message])
|
||||
parsed = _parse_caption_json(await _response_text(result))
|
||||
if not parsed["caption"] and not parsed["description"]:
|
||||
raise RuntimeError("Vision model returned no usable caption")
|
||||
return parsed
|
||||
|
||||
async def _read_image(self, file_path: str, source_path: Path) -> dict | None:
|
||||
"""Read the image file and build the VLM request payload.
|
||||
|
|
@ -156,19 +458,25 @@ class AutoImageResourceStep(BaseAutoResourceStep):
|
|||
if payload is None:
|
||||
return
|
||||
|
||||
parsed = await generate_image_caption(
|
||||
model,
|
||||
payload,
|
||||
self.prompt_format(
|
||||
"user_message",
|
||||
file_path=file_path,
|
||||
filename=PurePosixPath(file_path).name,
|
||||
stem=note_stem,
|
||||
date=date_str,
|
||||
),
|
||||
logger=self.logger,
|
||||
name=self.name,
|
||||
user_message = UserMsg(
|
||||
name="user",
|
||||
content=[
|
||||
TextBlock(
|
||||
text=self.prompt_format(
|
||||
"user_message",
|
||||
file_path=file_path,
|
||||
filename=PurePosixPath(file_path).name,
|
||||
stem=note_stem,
|
||||
date=date_str,
|
||||
),
|
||||
),
|
||||
DataBlock(
|
||||
source=Base64Source(data=payload["data_b64"], media_type=payload["mime"]),
|
||||
name="image",
|
||||
),
|
||||
],
|
||||
)
|
||||
parsed = await self._caption_with_retry(model, user_message)
|
||||
name = _sanitize_note_name(str(parsed.get("name") or ""), note_stem)
|
||||
caption = str(parsed.get("caption") or "").strip()
|
||||
description = str(parsed.get("description") or "").strip() or caption[:120]
|
||||
|
|
|
|||
|
|
@ -1,164 +0,0 @@
|
|||
"""Internal model overrides stay local to one AgentScope invocation."""
|
||||
|
||||
# pylint: disable=protected-access,missing-function-docstring
|
||||
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from agentscope.agent import Agent
|
||||
from agentscope.message import Msg
|
||||
from agentscope.model import ChatModelBase
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from reme.components.agent_wrapper.as_agent_wrapper import AsAgentWrapper
|
||||
from reme.components.application_context import ApplicationContext
|
||||
from reme.components.as_llm import BaseAsLLM
|
||||
from reme.enumeration import ComponentEnum
|
||||
|
||||
|
||||
class _Model(ChatModelBase):
|
||||
"""Local model double with no provider client or network calls."""
|
||||
|
||||
def __init__(self, name):
|
||||
self.model = name
|
||||
self.structured_calls = []
|
||||
|
||||
async def generate_structured_output(self, **kwargs):
|
||||
self.structured_calls.append(kwargs)
|
||||
return SimpleNamespace(
|
||||
content={"model": self.model},
|
||||
usage=SimpleNamespace(input_tokens=2, output_tokens=1),
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(name="models")
|
||||
async def model_fixture(tmp_path):
|
||||
context = ApplicationContext(workspace_dir=str(tmp_path))
|
||||
default = BaseAsLLM(name="default")
|
||||
default.model = _Model("default")
|
||||
vision = BaseAsLLM(name="vision")
|
||||
vision.model = _Model("vision")
|
||||
context.components = {ComponentEnum.AS_LLM: {"default": default, "vision": vision}}
|
||||
wrapper = AsAgentWrapper(
|
||||
app_context=context,
|
||||
system_prompt="Configured prompt",
|
||||
model_config={"max_retries": 1},
|
||||
)
|
||||
await default.start()
|
||||
await vision.start()
|
||||
await wrapper.start()
|
||||
yield SimpleNamespace(wrapper=wrapper, default=default, vision=vision, context=context)
|
||||
await wrapper.close()
|
||||
await vision.close()
|
||||
await default.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("override", [False, True, None])
|
||||
async def test_build_agent_binds_call_model_without_replacing_default(models, override):
|
||||
wrapper = models.wrapper
|
||||
before_kwargs = deepcopy(wrapper.kwargs)
|
||||
before_bindings = wrapper.dependency_bindings
|
||||
kwargs = {} if override is False else {"_model": models.vision.model if override else None}
|
||||
|
||||
agent, inputs = await wrapper._build_agent("hello", **kwargs)
|
||||
|
||||
assert isinstance(agent, Agent)
|
||||
assert isinstance(inputs, Msg)
|
||||
assert inputs.role == "user"
|
||||
assert agent.model is (models.vision.model if override else models.default.model)
|
||||
ordinary_agent, _ = await wrapper._build_agent("ordinary")
|
||||
assert ordinary_agent.model is models.default.model
|
||||
assert wrapper.as_llm is models.default
|
||||
assert wrapper.kwargs == before_kwargs
|
||||
assert wrapper.dependency_bindings == before_bindings
|
||||
assert wrapper._owned == []
|
||||
assert models.context.components == {ComponentEnum.AS_LLM: {"default": models.default, "vision": models.vision}}
|
||||
assert kwargs == ({} if override is False else {"_model": models.vision.model if override else None})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("override", ["vision", {}, object()])
|
||||
async def test_invalid_internal_model_override_fails_before_agent_construction(models, override):
|
||||
with pytest.raises(TypeError, match="ChatModelBase"):
|
||||
await models.wrapper._build_agent("hello", _model=override)
|
||||
assert models.wrapper.as_llm is models.default
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_replies_keep_agent_and_structured_output_on_each_call_model(models, monkeypatch):
|
||||
wrapper = models.wrapper
|
||||
barrier = asyncio.Barrier(3)
|
||||
observed = {}
|
||||
before_kwargs = deepcopy(wrapper.kwargs)
|
||||
before_bindings = wrapper.dependency_bindings
|
||||
|
||||
async def observe(agent, inputs):
|
||||
agent.state.context = [inputs]
|
||||
|
||||
async def reply(agent):
|
||||
label = agent.state.context[0].get_text_content()
|
||||
observed[label] = agent.model
|
||||
await asyncio.wait_for(barrier.wait(), timeout=5)
|
||||
assert wrapper.as_llm is models.default
|
||||
return SimpleNamespace(
|
||||
usage=SimpleNamespace(input_tokens=3, output_tokens=1),
|
||||
model_dump=lambda: {"text": label},
|
||||
get_text_content=lambda: label,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(Agent, "observe", observe)
|
||||
monkeypatch.setattr(Agent, "reply", reply)
|
||||
monkeypatch.setattr(wrapper, "_dump_state", AsyncMock())
|
||||
schema = {"type": "object"}
|
||||
kwargs = {"_model": models.vision.model, "output_schema": schema}
|
||||
|
||||
vision_result, ordinary_result, second_vision_result = await asyncio.gather(
|
||||
wrapper.reply("vision-first", **kwargs),
|
||||
wrapper.reply("ordinary", output_schema=schema),
|
||||
wrapper.reply("vision-second", **kwargs),
|
||||
)
|
||||
|
||||
assert observed == {
|
||||
"vision-first": models.vision.model,
|
||||
"ordinary": models.default.model,
|
||||
"vision-second": models.vision.model,
|
||||
}
|
||||
for result, expected in ((vision_result, "vision"), (ordinary_result, "default"), (second_vision_result, "vision")):
|
||||
assert result["structured_output"] == {"model": expected}
|
||||
assert result["usage"] == {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7}
|
||||
assert [call["messages"][0].get_text_content() for call in models.default.model.structured_calls] == ["ordinary"]
|
||||
assert {call["messages"][0].get_text_content() for call in models.vision.model.structured_calls} == {
|
||||
"vision-first",
|
||||
"vision-second",
|
||||
}
|
||||
assert wrapper.as_llm is models.default
|
||||
assert wrapper.kwargs == before_kwargs
|
||||
assert wrapper.dependency_bindings == before_bindings
|
||||
assert models.context.components == {ComponentEnum.AS_LLM: {"default": models.default, "vision": models.vision}}
|
||||
assert kwargs == {"_model": models.vision.model, "output_schema": {"type": "object"}}
|
||||
assert models.default.is_started and models.vision.is_started
|
||||
await wrapper.close()
|
||||
assert models.default.is_started and models.vision.is_started
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("override", [False, True])
|
||||
async def test_stream_builds_with_the_call_model_without_changing_default(models, monkeypatch, override):
|
||||
observed = []
|
||||
|
||||
async def reply_stream(agent, inputs):
|
||||
observed.append((agent.model, inputs.get_text_content()))
|
||||
if inputs is None:
|
||||
yield None
|
||||
|
||||
monkeypatch.setattr(Agent, "reply_stream", reply_stream)
|
||||
monkeypatch.setattr(models.wrapper, "_dump_state", AsyncMock())
|
||||
kwargs = {"_model": models.vision.model} if override else {}
|
||||
|
||||
assert [chunk async for chunk in models.wrapper.reply_stream("hello", **kwargs)] == []
|
||||
assert observed == [(models.vision.model if override else models.default.model, "hello")]
|
||||
assert models.wrapper.as_llm is models.default
|
||||
|
|
@ -9,7 +9,6 @@ with PIL inside a temporary workspace.
|
|||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
|
|
@ -29,14 +28,13 @@ from reme.components.component_registry import ComponentRegistry
|
|||
from reme.components.job import BaseJob
|
||||
from reme.components.runtime_context import RuntimeContext
|
||||
from reme.enumeration import ComponentEnum
|
||||
from reme.steps.evolve._image_caption import (
|
||||
from reme.steps.evolve.auto_image_resource import (
|
||||
AutoImageResourceStep,
|
||||
DEFAULT_MAX_IMAGE_PIXELS,
|
||||
_build_image_request_payload,
|
||||
generate_image_caption,
|
||||
_normalize_image_bytes,
|
||||
_parse_caption_json,
|
||||
)
|
||||
from reme.steps.evolve.auto_image_resource import AutoImageResourceStep
|
||||
from reme.steps.evolve.auto_resource import AutoResourceStep
|
||||
from reme.steps.evolve.auto_text_resource import AutoTextResourceStep
|
||||
from .auto_resource_test_support import (
|
||||
|
|
@ -1031,45 +1029,6 @@ def test_parse_caption_json(text, expected):
|
|||
assert _parse_caption_json(text) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("plain_fallback", [False, True])
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_caption_helper_preserves_request_without_workspace(plain_fallback, caplog):
|
||||
"""The shared VLM path needs only bytes, a model and an already rendered prompt."""
|
||||
expected = {"name": "image", "description": "Visible text", "caption": "Invoice 482."}
|
||||
model = _StructuredVisionModel(
|
||||
content=expected,
|
||||
error=RuntimeError("structured output unavailable") if plain_fallback else None,
|
||||
plain_text="```json\n" + _caption_json(**expected) + "\n```",
|
||||
)
|
||||
original = _png_bytes()
|
||||
payload = _build_image_request_payload(original, ".png")
|
||||
prompt = "Describe visible facts. Keep invoice numbers verbatim."
|
||||
|
||||
result = await generate_image_caption(
|
||||
model,
|
||||
payload,
|
||||
prompt,
|
||||
logger=logging.getLogger(__name__),
|
||||
name="shared-caption-test",
|
||||
)
|
||||
|
||||
assert result == expected
|
||||
assert len(model.structured_calls) == 1
|
||||
assert len(model.plain_calls) == int(plain_fallback)
|
||||
message = model.structured_calls[0][0]
|
||||
assert message.content[0].text == prompt
|
||||
assert message.content[1].source.media_type == "image/png"
|
||||
assert base64.b64decode(message.content[1].source.data) == original
|
||||
if plain_fallback:
|
||||
assert model.plain_calls[0][0] is message
|
||||
assert caplog.messages == [
|
||||
"[shared-caption-test] structured caption failed (structured output unavailable); "
|
||||
"retrying with a plain call",
|
||||
]
|
||||
else:
|
||||
assert not caplog.messages
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("structured_mode", "note_name", "caption", "expected_plain_calls"),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Direct image inputs select a per-call AgentScope model without caption calls."""
|
||||
"""Direct image inputs preserve native model binding and session contracts."""
|
||||
|
||||
# pylint: disable=protected-access,missing-function-docstring
|
||||
|
||||
|
|
@ -7,11 +7,9 @@ import base64
|
|||
import copy
|
||||
import io
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from agentscope.agent import Agent, ContextConfig
|
||||
from agentscope.formatter import OpenAIChatFormatter
|
||||
from agentscope.message import Base64Source, Msg, TextBlock, URLSource
|
||||
from PIL import Image
|
||||
import pytest
|
||||
|
|
@ -20,7 +18,7 @@ import yaml
|
|||
from reme.components.as_llm import BaseAsLLM
|
||||
from reme.components.runtime_context import RuntimeContext
|
||||
from reme.enumeration import ComponentEnum
|
||||
from reme.steps.evolve import _auto_memory_image, _image_caption
|
||||
from reme.steps.evolve import _auto_memory_image, auto_image_resource
|
||||
|
||||
from .test_auto_memory_image_modes import (
|
||||
_DAY,
|
||||
|
|
@ -63,7 +61,7 @@ def test_default_config_keeps_images_disabled_and_selects_direct_mode():
|
|||
@pytest.mark.asyncio
|
||||
async def test_enabled_default_is_direct_with_a_single_memory_reply(harness, monkeypatch):
|
||||
caption = AsyncMock(side_effect=AssertionError("Direct mode must not caption images"))
|
||||
monkeypatch.setattr(_image_caption, "generate_image_caption", caption)
|
||||
monkeypatch.setattr(auto_image_resource.AutoImageResourceStep, "_caption_with_retry", caption)
|
||||
message = _message()
|
||||
message.content[0].text += " Keep literal [Image 1]; image ID: gallery-before."
|
||||
message.content.insert(2, TextBlock(text="Between observations; image ID: gallery-after."))
|
||||
|
|
@ -102,7 +100,7 @@ async def test_enabled_default_is_direct_with_a_single_memory_reply(harness, mon
|
|||
assert harness.session_path.read_bytes() == _main_saved_line(message)
|
||||
assert not (harness.workspace / "resource").exists()
|
||||
caption.assert_not_called()
|
||||
assert kwargs["_model"] is harness.wrapper.as_llm.model
|
||||
assert "_model" not in kwargs
|
||||
assert "supports_vision" not in kwargs
|
||||
|
||||
formatted = await harness.wrapper.as_llm.model.formatter.format([inputs])
|
||||
|
|
@ -128,9 +126,6 @@ async def test_enabled_default_is_direct_with_a_single_memory_reply(harness, mon
|
|||
async def test_disabled_keeps_original_string_and_does_not_prepare(harness, monkeypatch, include_images):
|
||||
prepare = AsyncMock(side_effect=AssertionError("Disabled images must not be inspected"))
|
||||
monkeypatch.setattr("reme.steps.evolve.auto_memory.prepare_direct_message", prepare)
|
||||
original_component = harness.wrapper.as_llm
|
||||
original_model = original_component.model
|
||||
harness.app_context.components[ComponentEnum.AS_LLM]["vision"] = SimpleNamespace(model=_DirectModel("vision"))
|
||||
step = harness.step()
|
||||
kwargs = {} if include_images is None else {"include_images": include_images}
|
||||
message = _message()
|
||||
|
|
@ -143,16 +138,12 @@ async def test_disabled_keeps_original_string_and_does_not_prepare(harness, monk
|
|||
assert "[Image" not in harness.wrapper.calls[0][0]
|
||||
assert "auto_memory_images" not in step.context.response.metadata
|
||||
assert "_model" not in harness.wrapper.calls[0][1]
|
||||
assert harness.wrapper.as_llm is original_component
|
||||
assert original_component.model is original_model
|
||||
assert harness.session_path.read_bytes() == _main_saved_line(message)
|
||||
prepare.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_images_skips_model_selection_and_source_checks(harness, monkeypatch):
|
||||
original_model = harness.wrapper.as_llm.model
|
||||
harness.app_context.components[ComponentEnum.AS_LLM]["vision"] = None
|
||||
async def test_no_images_skips_source_checks(harness, monkeypatch):
|
||||
loader = AsyncMock(side_effect=AssertionError("No image should be loaded"))
|
||||
monkeypatch.setattr(_auto_memory_image, "_image_bytes", loader)
|
||||
|
||||
|
|
@ -163,7 +154,6 @@ async def test_no_images_skips_model_selection_and_source_checks(harness, monkey
|
|||
assert response.metadata["auto_memory_images"]["status"] == "skipped"
|
||||
assert response.metadata["auto_memory_images"]["image_count"] == 0
|
||||
assert "_model" not in harness.wrapper.calls[0][1]
|
||||
assert harness.wrapper.as_llm.model is original_model
|
||||
harness.logger.warning.assert_not_called()
|
||||
loader.assert_not_called()
|
||||
|
||||
|
|
@ -203,7 +193,7 @@ async def test_non_agentscope_wrapper_still_accepts_original_text_only_calls(har
|
|||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("declared,images", [(None, True), (False, True), (None, False)])
|
||||
async def test_missing_vision_declaration_does_not_read_images_or_choose_a_model(
|
||||
async def test_missing_vision_declaration_does_not_read_images(
|
||||
harness,
|
||||
monkeypatch,
|
||||
declared,
|
||||
|
|
@ -213,8 +203,6 @@ async def test_missing_vision_declaration_does_not_read_images_or_choose_a_model
|
|||
monkeypatch.setattr(_auto_memory_image, "_image_bytes", loader)
|
||||
step = harness.step()
|
||||
step.kwargs.pop("supports_vision")
|
||||
harness.wrapper.as_llm.model = None
|
||||
harness.app_context.components[ComponentEnum.AS_LLM]["vision"] = None
|
||||
message = _message(images=images)
|
||||
if images:
|
||||
message.content[1].source.data = "invalid-base64"
|
||||
|
|
@ -245,117 +233,28 @@ async def test_missing_vision_declaration_does_not_read_images_or_choose_a_model
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_catalog_and_formatter_declarations_do_not_gate_direct_inputs(harness, monkeypatch):
|
||||
model = _DirectModel("custom-deployment")
|
||||
model.formatter = OpenAIChatFormatter(input_types=["text/plain"])
|
||||
catalog = Mock(side_effect=AssertionError("Model capabilities belong to the caller"))
|
||||
monkeypatch.setattr(model, "list_models", catalog)
|
||||
harness.wrapper.as_llm.model = model
|
||||
|
||||
response = await _run(harness, [_message()])
|
||||
|
||||
assert response.metadata["auto_memory_images"]["status"] == "completed"
|
||||
assert isinstance(harness.wrapper.calls[0][0], Msg)
|
||||
assert harness.wrapper.calls[0][1]["_model"] is model
|
||||
catalog.assert_not_called()
|
||||
harness.logger.warning.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("wrapper_binding", ["default", "custom"])
|
||||
async def test_dedicated_vision_model_is_preferred_without_rebinding_shared_components(harness, wrapper_binding):
|
||||
@pytest.mark.parametrize("include_images", [False, True])
|
||||
async def test_memory_uses_bound_as_llm_even_when_another_vision_component_exists(harness, include_images):
|
||||
models = harness.app_context.components[ComponentEnum.AS_LLM]
|
||||
default_component = models["default"]
|
||||
custom_component = BaseAsLLM()
|
||||
custom_component.model = _DirectModel("custom-memory")
|
||||
vision_component = BaseAsLLM()
|
||||
vision_component.model = _DirectModel("dedicated-vision")
|
||||
models.update(custom=custom_component, vision=vision_component)
|
||||
harness.wrapper.as_llm = models[wrapper_binding]
|
||||
original_components = dict(models)
|
||||
original_models = {name: component.model for name, component in models.items()}
|
||||
wrapper_options = {"context_config": {"trigger_ratio": 0.7}, "model_config": {"max_retries": 2}}
|
||||
harness.wrapper.kwargs.update(wrapper_options)
|
||||
|
||||
response = await _run(harness, [_message()])
|
||||
|
||||
assert response.metadata["auto_memory_images"]["status"] == "completed"
|
||||
assert harness.wrapper.calls[0][1]["_model"] is vision_component.model
|
||||
assert harness.wrapper.as_llm is original_components[wrapper_binding]
|
||||
assert all(models[name] is component for name, component in original_components.items())
|
||||
assert all(models[name].model is model for name, model in original_models.items())
|
||||
assert harness.wrapper.kwargs["context_config"] is wrapper_options["context_config"]
|
||||
assert harness.wrapper.kwargs["model_config"] is wrapper_options["model_config"]
|
||||
assert wrapper_options == {"context_config": {"trigger_ratio": 0.7}, "model_config": {"max_retries": 2}}
|
||||
assert default_component.model is original_models["default"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("application_context", [True, False])
|
||||
async def test_absent_vision_uses_actual_custom_wrapper_binding(harness, application_context):
|
||||
default_component = harness.app_context.components[ComponentEnum.AS_LLM]["default"]
|
||||
custom_component = BaseAsLLM()
|
||||
custom_component.model = _DirectModel("custom-memory")
|
||||
harness.wrapper.as_llm = custom_component
|
||||
bound = BaseAsLLM()
|
||||
bound.model = _DirectModel("bound-memory")
|
||||
vision = BaseAsLLM()
|
||||
vision.model = _DirectModel("unused-resource-vision")
|
||||
models.update(custom=bound, vision=vision)
|
||||
harness.wrapper.as_llm = bound
|
||||
step = harness.step()
|
||||
if not application_context:
|
||||
step.app_context = None
|
||||
|
||||
await step(session_id=_SESSION, date=_DAY, messages=[_message()], include_images=True)
|
||||
await step(session_id=_SESSION, date=_DAY, messages=[_message()], include_images=include_images)
|
||||
|
||||
assert step.context.response.metadata["auto_memory_images"]["status"] == "completed"
|
||||
assert harness.wrapper.calls[0][1]["_model"] is custom_component.model
|
||||
assert harness.wrapper.calls[0][1]["_model"] is not default_component.model
|
||||
assert harness.wrapper.as_llm is custom_component
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("unavailable", ["bound-model", "vision-model", "vision-component"])
|
||||
async def test_selected_model_must_be_started_without_silent_model_substitution(harness, monkeypatch, unavailable):
|
||||
if unavailable == "bound-model":
|
||||
harness.wrapper.as_llm.model = None
|
||||
elif unavailable == "vision-model":
|
||||
harness.app_context.components[ComponentEnum.AS_LLM]["vision"] = BaseAsLLM()
|
||||
else:
|
||||
harness.app_context.components[ComponentEnum.AS_LLM]["vision"] = None
|
||||
original_component = harness.wrapper.as_llm
|
||||
original_model = original_component.model
|
||||
loader = AsyncMock(side_effect=AssertionError("Missing model must be rejected before image IO"))
|
||||
monkeypatch.setattr(_auto_memory_image, "_image_bytes", loader)
|
||||
|
||||
response = await _run(harness, [_message()])
|
||||
|
||||
assert response.metadata["auto_memory_images"]["status"] == "fallback"
|
||||
assert response.metadata["auto_memory_images"]["reason"] == "model: ValueError"
|
||||
assert isinstance(harness.wrapper.calls[0][0], str)
|
||||
assert "_model" not in harness.wrapper.calls[0][1]
|
||||
assert "context_config" not in harness.wrapper.calls[0][1]
|
||||
assert harness.wrapper.as_llm is original_component
|
||||
assert original_component.model is original_model
|
||||
loader.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_hook_model_options_are_read_once_and_passed_unchanged(harness, monkeypatch):
|
||||
fallback = _DirectModel()
|
||||
fallback.formatter = OpenAIChatFormatter(input_types=["text/plain"])
|
||||
harness.wrapper.kwargs["model_config"] = {"max_retries": 2}
|
||||
model_config = {"fallback_model": fallback}
|
||||
options = {"model_config": model_config}
|
||||
step = harness.step()
|
||||
hook = Mock(return_value=options)
|
||||
monkeypatch.setattr(step, "_reply_extra_kwargs", hook)
|
||||
|
||||
await step(session_id=_SESSION, date=_DAY, messages=[_message()], include_images=True)
|
||||
|
||||
assert step.context.response.metadata["auto_memory_images"]["status"] == "completed"
|
||||
assert isinstance(harness.wrapper.calls[0][0], Msg)
|
||||
assert harness.wrapper.calls[0][1]["model_config"] is model_config
|
||||
assert harness.wrapper.calls[0][1]["_model"] is harness.wrapper.as_llm.model
|
||||
assert harness.wrapper.kwargs["model_config"] == {"max_retries": 2}
|
||||
assert model_config["fallback_model"] is fallback
|
||||
assert options == {"model_config": model_config}
|
||||
hook.assert_called_once_with(_DAY)
|
||||
inputs, kwargs = harness.wrapper.calls[0]
|
||||
assert isinstance(inputs, Msg if include_images else str)
|
||||
assert "_model" not in kwargs and "supports_vision" not in kwargs
|
||||
# Exercise native Agent construction without resolving the test's file tools.
|
||||
agent, _ = await harness.wrapper._build_agent(inputs, **{**kwargs, "job_tools": []})
|
||||
assert agent.model is bound.model
|
||||
assert harness.wrapper.as_llm is bound
|
||||
assert models["default"] is not bound
|
||||
assert models["vision"] is vision
|
||||
|
||||
|
||||
def _six_image_message():
|
||||
|
|
@ -518,10 +417,8 @@ async def test_interleaving_keeps_localized_create_update_prompt_boundaries(harn
|
|||
@pytest.mark.parametrize("template", ["No history requested.", "{history}\n{history}"])
|
||||
async def test_missing_or_repeated_history_falls_back_without_direct_overrides(harness, monkeypatch, template):
|
||||
step = harness.step(prompt_dict={"user_message_create": template})
|
||||
original_model = harness.wrapper.as_llm.model
|
||||
original_options = {"context_config": {"trigger_ratio": 0.7}, "model_config": {"max_retries": 2}}
|
||||
monkeypatch.setattr(step, "_reply_extra_kwargs", Mock(return_value=original_options))
|
||||
harness.app_context.components[ComponentEnum.AS_LLM]["vision"] = SimpleNamespace(model=_DirectModel("vision"))
|
||||
message = _message()
|
||||
|
||||
await step(session_id=_SESSION, date=_DAY, messages=[message], include_images=True)
|
||||
|
|
@ -536,7 +433,6 @@ async def test_missing_or_repeated_history_falls_back_without_direct_overrides(h
|
|||
assert "_model" not in options
|
||||
assert options["context_config"] is original_options["context_config"]
|
||||
assert original_options == {"context_config": {"trigger_ratio": 0.7}, "model_config": {"max_retries": 2}}
|
||||
assert harness.wrapper.as_llm.model is original_model
|
||||
harness.logger.warning.assert_called_once()
|
||||
assert harness.session_path.read_bytes() == _main_saved_line(message)
|
||||
|
||||
|
|
@ -570,7 +466,7 @@ async def test_direct_uses_shared_normalized_provider_bytes(harness):
|
|||
before = message.model_dump()
|
||||
step = harness.step()
|
||||
step.context = RuntimeContext()
|
||||
expected = _image_caption._build_image_request_payload(original_data, "")
|
||||
expected = auto_image_resource._build_image_request_payload(original_data, "")
|
||||
|
||||
direct_message = await _auto_memory_image.prepare_direct_message(step, [message], step._format_history)
|
||||
|
||||
|
|
@ -578,7 +474,7 @@ async def test_direct_uses_shared_normalized_provider_bytes(harness):
|
|||
assert image_input.media_type == expected["mime"] == "image/jpeg"
|
||||
assert image_input.data == expected["data_b64"]
|
||||
with Image.open(io.BytesIO(base64.b64decode(image_input.data))) as image:
|
||||
assert max(image.size) == _image_caption.MAX_IMAGE_REQUEST_DIMENSION
|
||||
assert max(image.size) == auto_image_resource.MAX_IMAGE_REQUEST_DIMENSION
|
||||
assert message.model_dump() == before
|
||||
|
||||
|
||||
|
|
@ -607,10 +503,6 @@ async def test_second_image_failure_discards_all_prepared_inputs(harness, failur
|
|||
message = _six_image_message()
|
||||
source_config = {"trigger_ratio": 0.7}
|
||||
harness.wrapper.kwargs["context_config"] = source_config
|
||||
original_component = harness.wrapper.as_llm
|
||||
original_model = original_component.model
|
||||
vision_model = _DirectModel("dedicated-vision")
|
||||
harness.app_context.components[ComponentEnum.AS_LLM]["vision"] = SimpleNamespace(model=vision_model)
|
||||
kwargs = {}
|
||||
if failure == "invalid-base64":
|
||||
message.content[2].source.data = "INVALID_BASE64!!!"
|
||||
|
|
@ -637,11 +529,7 @@ async def test_second_image_failure_discards_all_prepared_inputs(harness, failur
|
|||
assert "Remember this observation." in harness.wrapper.calls[0][0]
|
||||
assert response.metadata["auto_memory_images"]["captioned_images"] == 0
|
||||
assert "context_config" not in harness.wrapper.calls[0][1]
|
||||
assert "_model" not in harness.wrapper.calls[0][1]
|
||||
assert source_config == {"trigger_ratio": 0.7}
|
||||
assert harness.wrapper.as_llm is original_component
|
||||
assert original_component.model is original_model
|
||||
assert harness.app_context.components[ComponentEnum.AS_LLM]["vision"].model is vision_model
|
||||
harness.logger.warning.assert_called_once()
|
||||
assert message.model_dump() == before
|
||||
assert harness.session_path.read_bytes() == _main_saved_line(message)
|
||||
|
|
|
|||
|
|
@ -112,17 +112,13 @@ class _MemoryWrapper(BaseAgentWrapper):
|
|||
|
||||
|
||||
class _DirectModel(ChatModelBase):
|
||||
"""Local model with no API client or model-catalog requirement."""
|
||||
"""Local model with no API client."""
|
||||
|
||||
def __init__(self, name="custom-memory-model"):
|
||||
self.model = name
|
||||
self.context_size = 200000
|
||||
self.formatter = OpenAIChatFormatter()
|
||||
|
||||
def list_models(self, custom_yaml_dir=None):
|
||||
del custom_yaml_dir
|
||||
raise AssertionError("Direct image routing must not inspect model catalogs")
|
||||
|
||||
|
||||
class _DirectWrapper(AsAgentWrapper):
|
||||
"""Keep a real wrapper type and binding while observing the reply boundary."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue