feat: workspace file references, read-file-images middleware, image model enabled flag

In-progress work committed to unblock the config import/export plan:
- prompts: FILE_REFERENCES section for workspace-relative file citation
- backends: resolve quoted virtual absolute paths onto the sandbox workspace
- middleware: read_file_images middleware; message_budget extensions
- image_gen/model_registry: image model 'enabled' flag refactor
- memory/launch, gateway/background_runs, tools/image follow-ons
- scripts: dev_backend.sh, release.sh
- tests for the above
This commit is contained in:
m4
2026-08-12 19:42:37 +08:00
parent f3ca381ab3
commit aae8d0a379
28 changed files with 2156 additions and 59 deletions
+47 -9
View File
@@ -682,6 +682,27 @@ def _guard_bare_absolute(result: str | None) -> str | None:
return result
def _resolve_workspace_quoted_path(path: str, workspace_root: str) -> str | None:
"""Map a quoted virtual absolute path onto the sandbox workspace.
Returns the ``./...`` form of *path* when its parent directory exists
under *workspace_root*, else ``None``. Parent-existence is what
disambiguates workspace paths (``/微信图片.jpg``, ``/artifacts/new.png``
— including files not written yet) from real system paths
(``/etc/hosts``, ``/bin/echo``), which stay untouched.
"""
rel = posixpath.normpath(path.lstrip("/"))
if rel in ("", "."):
return "."
if rel == ".." or rel.startswith("../"):
return None
parent_rel = posixpath.dirname(rel)
parent = Path(workspace_root) / parent_rel if parent_rel else Path(workspace_root)
if not parent.is_dir():
return None
return "./" + rel
def _rewrite_quoted_path(
path: str,
workspace_name: str | None,
@@ -720,6 +741,7 @@ def _rewrite_quoted_path(
def convert_virtual_paths_in_command(
command: str,
workspace_name: str | None = None,
workspace_root: str | None = None,
) -> str:
"""Convert virtual paths (starting with ``/``) in commands to relative paths.
@@ -730,21 +752,36 @@ def convert_virtual_paths_in_command(
mount (``/skills/...``, ``/memories/...``) or a workspace-prefixed
system path are rewritten as a single shell token — this fixes #237
where ``python "/skills/my skill/main.py"`` was truncated at the
embedded space. Bare quoted ``/...`` paths (e.g. ``echo "/hi"``)
are left untouched since their semantics are ambiguous.
embedded space. When *workspace_root* is given, quoted ``/...`` paths
whose parent directory exists under the workspace (e.g. an uploaded
file the WebUI references as ``/<name>``) are rewritten to ``./...``
as well; quoted paths whose parent is absent from the workspace
(``/etc/hosts``, ``/bin/echo``) are left untouched.
After pre-processing, the original regex handles unquoted
paths and workspace-name correction as before.
"""
# Pre-process: rewrite quoted paths whose decoded content starts with /
def _rewrite_quoted(match: re.Match[str]) -> str:
quote = match.group(1)
decoded = re.sub(r"\\(.)", r"\1", match.group(2))
# Quoted workspace paths (e.g. an uploaded file the WebUI references
# as ``/<name>``) would hit the host root inside scripts. Map them
# onto the workspace, keeping the original quote char — inside
# heredoc code the quotes are syntax, and shlex.quote's bare form
# would corrupt the code.
if workspace_root is not None and decoded.startswith("/"):
mapped = _resolve_workspace_quoted_path(decoded, workspace_root)
if mapped == ".":
return "."
if mapped is not None and quote not in mapped:
return quote + mapped + quote
return (
_rewrite_quoted_path(decoded, workspace_name) or match.group(0)
)
command = re.sub(
r'(["\'])((?:\\.|(?!\1).)*?)\1',
lambda m: (
_rewrite_quoted_path(
re.sub(r"\\(.)", r"\1", m.group(2)),
workspace_name,
)
or m.group(0)
),
_rewrite_quoted,
command,
)
@@ -1103,6 +1140,7 @@ def prepare_sandbox_command(
command = convert_virtual_paths_in_command(
command=command,
workspace_name=Path(cwd_str).name,
workspace_root=cwd_str,
)
# Skills/memory dirs must be allowlisted: the workspace-literal replace above runs
# before the resolver, so any absolute path it later injects reaches validate unstripped.
+3 -1
View File
@@ -476,7 +476,9 @@ async def alaunch_background_run(
thread_id,
name=request.name,
)
payload = request.run_payload(thread_id)
# Payload builders may do blocking work (source-thread metadata
# fetch, snapshot creation against SQLite); keep it off the loop.
payload = await asyncio.to_thread(request.run_payload, thread_id)
run_id = await _acreate_run(
client,
thread_id=thread_id,
+8 -5
View File
@@ -201,13 +201,16 @@ class OpenAIImageAdapter:
"response_format": "b64_json",
}
image_mime = mimetypes.guess_type(image.name)[0] or "application/octet-stream"
files: list[tuple[str, tuple[str, Any, str]]] = []
with image.open("rb") as image_fh:
files.append(("image", (image.name, image_fh.read(), image_mime)))
# Read uploads off the event loop: the dev server aborts on blocking
# file I/O inside async tool calls.
image_bytes = await asyncio.to_thread(image.read_bytes)
files: list[tuple[str, tuple[str, Any, str]]] = [
("image", (image.name, image_bytes, image_mime))
]
if mask is not None:
mask_mime = mimetypes.guess_type(mask.name)[0] or "application/octet-stream"
with mask.open("rb") as mask_fh:
files.append(("mask", (mask.name, mask_fh.read(), mask_mime)))
mask_bytes = await asyncio.to_thread(mask.read_bytes)
files.append(("mask", (mask.name, mask_bytes, mask_mime)))
headers = {"Authorization": f"Bearer {self._api_key()}"}
try:
if self._client is not None:
+1 -2
View File
@@ -43,8 +43,7 @@ class ImageModelEntry(BaseModel):
provider: Literal["openai", "gemini"] = "openai"
api_key: str = Field("", description="API key or ${ENV_VAR} reference")
base_url: str = Field("", description="API base URL or ${ENV_VAR} reference")
supports_generation: bool = True
supports_edit: bool = True
enabled: bool = True
default_size: str = "1024x1024"
default_quality: str = "auto"
params: dict[str, Any] = Field(default_factory=dict)
+59 -20
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
import asyncio
import re
import time
from datetime import datetime
from pathlib import Path, PurePosixPath
from typing import Any
@@ -47,15 +49,22 @@ def _resolve_entry(model: str | None) -> tuple[ImageModelEntry, float]:
f"Unknown image model {model!r}."
+ _available_hint(settings.models)
)
if not entry.enabled:
raise ImageGenError(
f"Image model {entry.display_name()!r} is disabled."
)
return entry, settings.timeout_seconds
default = settings.default_model or settings.models[0].id
entry = settings.find_model(default)
if entry is None:
raise ImageGenError(
f"Default image model {default!r} is not in the models list."
+ _available_hint(settings.models)
)
return entry, settings.timeout_seconds
if entry is not None and entry.enabled:
return entry, settings.timeout_seconds
for candidate in settings.models:
if candidate.enabled:
return candidate, settings.timeout_seconds
raise ImageGenError(
"There are no enabled image models; enable one in the "
"image_generation section of config.yaml."
)
def _adapter_for(entry: ImageModelEntry, timeout: float) -> ImageGenAdapter:
@@ -145,6 +154,27 @@ def _success(paths: list[str], *, model: str, size: str) -> dict[str, Any]:
}
TEST_PROMPT = "A single red circle centered on a plain white background."
async def test_entry(entry: ImageModelEntry, *, timeout: float) -> int:
"""Generate one probe image against ``entry``; returns latency in ms.
The image bytes are discarded: this only proves the configured provider,
credentials and defaults can complete a real generation call.
"""
adapter = _adapter_for(entry, timeout)
started = time.monotonic()
await adapter.generate(
prompt=TEST_PROMPT,
size=entry.default_size,
quality=entry.default_quality,
background="auto",
n=1,
)
return int((time.monotonic() - started) * 1000)
async def generate_for_workspace(
workspace: Path,
*,
@@ -156,15 +186,20 @@ async def generate_for_workspace(
output_path: str | None = None,
n: int = 1,
) -> dict[str, Any]:
entry, timeout = _resolve_entry(model)
if not entry.supports_generation:
raise ImageGenError(f"Image model {entry.display_name()!r} cannot generate.")
# Config load and file I/O run in a thread: the langgraph dev server
# intercepts blocking calls (os.mkdir, read_text, stat) made directly on
# the event loop and aborts the tool call.
entry, timeout = await asyncio.to_thread(_resolve_entry, model)
adapter = _adapter_for(entry, timeout)
images = await adapter.generate(
prompt=prompt, size=size, quality=quality, background=background, n=n
)
saved = _save_images(
workspace, images, output_path=output_path, default_stem="generated"
saved = await asyncio.to_thread(
_save_images,
workspace,
images,
output_path=output_path,
default_stem="generated",
)
return _success(saved, model=entry.id, size=size)
@@ -180,18 +215,22 @@ async def edit_for_workspace(
quality: str = "auto",
output_path: str | None = None,
) -> dict[str, Any]:
entry, timeout = _resolve_entry(model)
if not entry.supports_edit:
raise ImageGenError(
f"Image model {entry.display_name()!r} does not support edit."
)
image = _resolve_input_image(workspace, image_path)
mask = _resolve_input_image(workspace, mask_path) if mask_path else None
entry, timeout = await asyncio.to_thread(_resolve_entry, model)
image = await asyncio.to_thread(_resolve_input_image, workspace, image_path)
mask = (
await asyncio.to_thread(_resolve_input_image, workspace, mask_path)
if mask_path
else None
)
adapter = _adapter_for(entry, timeout)
images = await adapter.edit(
image=image, mask=mask, prompt=prompt, size=size, quality=quality
)
saved = _save_images(
workspace, images, output_path=output_path, default_stem="edited"
saved = await asyncio.to_thread(
_save_images,
workspace,
images,
output_path=output_path,
default_stem="edited",
)
return _success(saved, model=entry.id, size=size)
+109
View File
@@ -3,6 +3,8 @@
from __future__ import annotations
import json
import logging
import uuid
from collections.abc import Callable
from pathlib import Path
from typing import cast
@@ -17,6 +19,7 @@ from ..gateway.background_runs import (
launch_background_run,
)
from ..langgraph_dev.sdk import messages_input
from ..model_registry.schemas import ModelRef, ReasoningEffort
from .observations import build_observation_linker_index_context
from .scheduler import ObservationLinkerContext
from .source_context import MemorySourceContext, _trajectory_for_prompt
@@ -35,6 +38,8 @@ from .worker_activity import (
snapshot_observation_relations,
)
logger = logging.getLogger(__name__)
SUBAGENT_MEMORY_WORKER_GRAPH_ID = "evomemory-subagent-worker"
TURN_MEMORY_WORKER_GRAPH_ID = "evomemory-turn-worker"
OBSERVATION_LINKER_GRAPH_ID = "evomemory-observation-linker"
@@ -107,6 +112,107 @@ def _memory_worker_metadata(context: MemorySourceContext) -> dict[str, str]:
return metadata
def _source_thread_model_selection(
session_id: str,
) -> tuple[ModelRef, ReasoningEffort | None, int] | None:
"""Read the source conversation thread's explicit model selection.
Returns ``(primary, reasoning_effort, revision)`` for an explicit
ThreadModelSelection, or ``None`` when the thread is unreadable, has no
selection, or is set to ``inherit`` — in those cases the worker falls
back to the section 8.1 lazy local snapshot (registry default). A legacy
``auxiliary`` key in stored metadata is tolerated and dropped, matching
the BFF validator.
"""
from langgraph_sdk import get_sync_client
from ..langgraph_dev.sdk import (
configured_langgraph_dev_url,
langgraph_dev_headers,
)
client = get_sync_client(
url=configured_langgraph_dev_url(),
headers=langgraph_dev_headers(None),
)
thread = client.threads.get(session_id)
metadata = (
thread.get("metadata") if isinstance(thread, dict) else getattr(thread, "metadata", None)
)
if not isinstance(metadata, dict):
return None
raw = metadata.get("model_selection")
if not isinstance(raw, dict):
return None
primary_raw = raw.get("primary")
if not isinstance(primary_raw, dict):
return None
provider_id = primary_raw.get("provider_id")
model_key = primary_raw.get("model_key")
if (
not isinstance(provider_id, str)
or not provider_id
or not isinstance(model_key, str)
or not model_key
):
return None
effort_raw = raw.get("reasoning_effort")
effort: ReasoningEffort | None = (
effort_raw if effort_raw in ("low", "medium", "high") else None
)
revision_raw = metadata.get("model_selection_revision")
revision = revision_raw if isinstance(revision_raw, int) and revision_raw >= 0 else 0
return (ModelRef(provider_id=provider_id, model_key=model_key), effort, revision)
def _worker_snapshot_id(context: MemorySourceContext, worker_thread_id: str) -> str | None:
"""Freeze the source conversation's model selection for the worker run.
The worker runs on its own thread, so the conversation's snapshot cannot
be reused (bindings are per-thread); a fresh snapshot with the same
selection is created and bound to the worker thread instead. Returns
``None`` to keep the section 8.1 lazy-default behavior when the source
thread has no explicit selection or any step fails — memory work must
never fail just because selection inheritance did.
"""
try:
selection = _source_thread_model_selection(context.session_id)
except Exception:
logger.warning(
"Memory worker: could not read source thread %s model selection; "
"falling back to the registry default",
context.session_id,
exc_info=True,
)
return None
if selection is None:
return None
primary, reasoning_effort, revision = selection
from ..model_registry.runtime import get_snapshot_runtime
from ..model_registry.snapshots import SnapshotCreateRequest
try:
runtime = get_snapshot_runtime()
creation = runtime.snapshots.create( SnapshotCreateRequest(
run_request_id=uuid.uuid4().hex,
thread_id=worker_thread_id,
deployment_id=runtime.local_deployment_id,
model_selection_revision=revision,
primary=primary,
reasoning_effort=reasoning_effort,
)
)
except Exception:
logger.warning(
"Memory worker: could not freeze the source selection into a run "
"snapshot; falling back to the registry default",
exc_info=True,
)
return None
return creation.snapshot.snapshot_id
def _memory_worker_run_payload(
*,
context: MemorySourceContext,
@@ -121,6 +227,9 @@ def _memory_worker_run_payload(
"evomemory_project_id": context.project_id,
"evomemory_trajectory_digest": context.trajectory_digest,
}
snapshot_id = _worker_snapshot_id(context, thread_id)
if snapshot_id is not None:
configurable["runtime_snapshot_id"] = snapshot_id
from ..usage.callback import usage_tracking_requested
if usage_tracking_requested() and context.turn_id:
+7
View File
@@ -40,6 +40,13 @@ from .tool_error_handler import ToolErrorHandlerMiddleware
from .tool_selector import create_tool_selector_middleware
from .utils import disable_thinking
# Patches the deepagents FilesystemMiddleware class so every read_file tool
# (main agent + sub-agents) post-processes image results. Must run before any
# create_deep_agent call; this package is imported on every agent-build path.
from .read_file_images import install_read_file_image_patch
install_read_file_image_patch()
__all__ = [
"AskUserMiddleware",
"AskUserRequest",
+111 -3
View File
@@ -41,6 +41,7 @@ tool/attachment modes), so the middleware does not re-check it per call.
from __future__ import annotations
import asyncio
import logging
import threading
from collections.abc import Iterable, Mapping
from contextvars import ContextVar
@@ -64,6 +65,8 @@ _KEEP_FRACTION = 0.35
_MODEL_ROLES = get_args(ModelRole)
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class MessageBudget:
@@ -75,12 +78,52 @@ class MessageBudget:
keep_tokens: int
has_tools: bool
has_attachments: bool
# Fixed reserves (system + tools + attachments) consumed by this call;
# used_tokens + reserved_tokens approximates total context occupancy.
reserved_tokens: int = 0
_ACTIVE_BUDGET: ContextVar[MessageBudget | None] = ContextVar(
"evoscientist_message_budget", default=None
)
# The message-text total the base class computed just before deciding to
# summarize; stashed per call so a freshly created ``_summarization_event``
# can record the pre-compression estimate alongside the post-compression one.
_LAST_TOTAL_TOKENS: ContextVar[int | None] = ContextVar(
"evoscientist_message_budget_total", default=None
)
def _augment_summarization_event(
response: Any, budget: MessageBudget, tokens_after: list[int]
) -> None:
"""Attach estimate token stats to a freshly created summarization event.
``count_message_text_tokens`` is a character estimate, so the fields are
named ``estimated_*``; the provider-measured sizes keep flowing through
the usage pipeline (``input_tokens`` on the next model call reflects the
compressed prompt). Responses without a new ``_summarization_event``
update pass through untouched.
"""
command = getattr(response, "command", None)
update = getattr(command, "update", None)
if not isinstance(update, dict):
return
event = update.get("_summarization_event")
if not isinstance(event, dict):
return
before = _LAST_TOTAL_TOKENS.get()
if before is not None:
event["estimated_tokens_before"] = before
if tokens_after:
event["estimated_tokens_after"] = tokens_after[0]
event["budget"] = {
"hard_tokens": budget.hard_tokens,
"soft_tokens": budget.soft_tokens,
"keep_tokens": budget.keep_tokens,
}
def _content_text(content: Any) -> str:
"""Extract text without serializing image/file blocks or tool schemas."""
@@ -149,9 +192,40 @@ def _snapshot_message_budget(
keep_tokens=max(1, int(hard * _KEEP_FRACTION)),
has_tools=has_tools,
has_attachments=has_attachments,
reserved_tokens=budget.resolved_input_limit - message_budget,
)
def _emit_context_usage(budget: MessageBudget, used_tokens: int) -> None:
"""Stream the prompt size of the model call about to happen.
Emitted on the LangGraph ``custom`` stream mode so the UI can render a
live context-occupancy indicator; ``used_tokens`` is the character
estimate of the messages actually sent (provider-measured sizes keep
flowing through the usage pipeline). Outside a runnable context (unit
tests, sync drivers) there is no stream writer — skip silently.
"""
try:
from langgraph.config import get_stream_writer
writer = get_stream_writer()
except Exception:
return
try:
writer(
{
"type": "evoscientist_context_usage",
"used_tokens": used_tokens,
"reserved_tokens": budget.reserved_tokens,
"input_limit": budget.input_limit,
"hard_tokens": budget.hard_tokens,
"soft_tokens": budget.soft_tokens,
}
)
except Exception:
logger.warning("Failed to emit context-usage stream event", exc_info=True)
class MessageBudgetMiddleware:
"""Factory namespace kept separate from DeepAgents' concrete middleware."""
@@ -285,9 +359,28 @@ class MessageBudgetMiddleware:
return active
def wrap_model_call(self, request: Any, handler: Any) -> Any:
token = _ACTIVE_BUDGET.set(self._budget_for_request(request))
budget = self._budget_for_request(request)
token = _ACTIVE_BUDGET.set(budget)
tokens_after: list[int] = []
def counting_handler(modified_request: Any) -> Any:
# The last handler invocation carries the exact prompt
# sent to the model (summary + preserved tail after
# compaction); earlier attempts are superseded.
tokens_after[:] = [
count_message_text_tokens(modified_request.messages)
]
return handler(modified_request)
try:
return super().wrap_model_call(request, handler)
response = super().wrap_model_call(request, counting_handler)
# Emitted from the middleware body, not the handler: the
# base class may invoke the handler on an executor thread
# where the LangGraph stream-writer context is absent.
if tokens_after:
_emit_context_usage(budget, tokens_after[0])
_augment_summarization_event(response, budget, tokens_after)
return response
finally:
_ACTIVE_BUDGET.reset(token)
@@ -296,8 +389,22 @@ class MessageBudgetMiddleware:
# off the event loop (langgraph dev's blockbuster rejects it).
budget = await asyncio.to_thread(self._budget_for_request, request)
token = _ACTIVE_BUDGET.set(budget)
tokens_after: list[int] = []
async def counting_handler(modified_request: Any) -> Any:
tokens_after[:] = [
count_message_text_tokens(modified_request.messages)
]
return await handler(modified_request)
try:
return await super().awrap_model_call(request, handler)
response = await super().awrap_model_call(
request, counting_handler
)
if tokens_after:
_emit_context_usage(budget, tokens_after[0])
_augment_summarization_event(response, budget, tokens_after)
return response
finally:
_ACTIVE_BUDGET.reset(token)
@@ -317,6 +424,7 @@ class MessageBudgetMiddleware:
self, messages: list[AnyMessage], total_tokens: int
) -> bool:
del messages
_LAST_TOTAL_TOKENS.set(total_tokens)
return total_tokens >= self._active_budget().soft_tokens
def _determine_cutoff_index(self, messages: list[AnyMessage]) -> int:
+223
View File
@@ -0,0 +1,223 @@
"""read_file image post-processing: content sniffing, downsampling, metadata.
Upstream deepagents builds multimodal ToolMessages for binary reads, but it
guesses the MIME type from the file extension and ships the raw bytes
verbatim — a multi-MB PNG goes straight to the provider, which may reject or
truncate it (the historical "read_file returns empty for images" report), and
a corrupt image silently reaches the model as undecodable base64.
This module wraps ``FilesystemMiddleware._create_read_file_tool`` (installed
at import time from ``EvoScientist.middleware``) and post-processes
successful image ToolMessages:
- content sniffing via Pillow (extension-independent);
- downsampling to a 2048px max edge, re-encoded as JPEG (or PNG when the
image carries alpha), with a metadata text block preserving the original
width/height/format and whether scaling happened;
- explicit errors ("无法读取图像数据: ...") when the payload is not a
decodable image — never a fake empty success.
Non-image results and small, correctly-typed images pass through untouched
(byte-identical to upstream).
"""
from __future__ import annotations
import asyncio
import base64
import functools
import io
from typing import Any
from langchain_core.messages import ToolMessage
from langchain_core.tools import BaseTool, StructuredTool
MAX_IMAGE_EDGE = 2048
_JPEG_QUALITY = 85
_installed = False
def _image_block(message: ToolMessage) -> dict[str, Any] | None:
"""Return the single base64 image block of an upstream read_file result."""
if message.status != "success":
return None
content = message.content
if not isinstance(content, list) or len(content) != 1:
return None
block = content[0]
if not isinstance(block, dict) or not isinstance(block.get("base64"), str):
return None
media_type = message.additional_kwargs.get("read_file_media_type") or ""
if block.get("type") == "image" or str(media_type).startswith("image/"):
return block
return None
def process_image_tool_message(message: ToolMessage) -> ToolMessage:
"""Sniff / downsample / re-encode an upstream read_file image result."""
block = _image_block(message)
if block is None:
return message
path = str(message.additional_kwargs.get("read_file_path", ""))
declared_mime = str(
message.additional_kwargs.get("read_file_media_type")
or block.get("mime_type")
or ""
)
# SVG is vector markup; Pillow cannot parse it and vision providers that
# accept it want the original bytes.
if "svg" in declared_mime or path.lower().endswith(".svg"):
return message
from PIL import Image
try:
raw = base64.standard_b64decode(block["base64"])
image = Image.open(io.BytesIO(raw))
image.load()
except Exception as exc:
return ToolMessage(
content=f"Error: 无法读取图像数据 '{path}': {exc}",
name=message.name,
tool_call_id=message.tool_call_id,
status="error",
)
orig_width, orig_height = image.size
orig_format = image.format or "unknown"
frames = int(getattr(image, "n_frames", 1))
sniffed_mime = Image.MIME.get(orig_format, declared_mime)
scaled = max(orig_width, orig_height) > MAX_IMAGE_EDGE
parts = [f"{path}: {orig_width}x{orig_height} {orig_format}"]
if frames > 1:
parts.append(f"first of {frames} frames")
# No transform needed: keep the original bytes (no re-encode quality
# loss), but still attach the metadata text block. On OpenAI-compatible
# providers the image is hoisted out of the tool message into a following
# user message, and this text is what the tool result itself carries —
# without it the model sees an empty tool result and the chat UI renders
# the tool box blank ("(empty)").
if not scaled and sniffed_mime == declared_mime:
return ToolMessage(
content=[
{"type": "text", "text": "[read_file image] " + ", ".join(parts)},
block,
],
name=message.name,
tool_call_id=message.tool_call_id,
status="success",
additional_kwargs=message.additional_kwargs,
)
has_alpha = image.mode in ("RGBA", "LA") or (
image.mode == "P" and "transparency" in image.info
)
work = image
if scaled:
work = work.copy()
work.thumbnail((MAX_IMAGE_EDGE, MAX_IMAGE_EDGE), Image.LANCZOS)
if has_alpha:
out_format, out_mime = "PNG", "image/png"
if work.mode == "P":
work = work.convert("RGBA")
else:
# JPEG drops alpha; only taken when there is none. WebP was considered
# but JPEG decodes on every vision endpoint.
out_format, out_mime = "JPEG", "image/jpeg"
if work.mode != "RGB":
work = work.convert("RGB")
try:
buffer = io.BytesIO()
work.save(buffer, out_format, quality=_JPEG_QUALITY, optimize=True)
except Exception as exc:
return ToolMessage(
content=f"Error: 图像转码失败 '{path}': {exc}",
name=message.name,
tool_call_id=message.tool_call_id,
status="error",
)
out_width, out_height = work.size
if scaled:
parts.append(
f"downsampled to {out_width}x{out_height} {out_format} "
f"(max edge {MAX_IMAGE_EDGE}px)"
)
else:
parts.append(f"re-encoded as {out_format}")
metadata = "[read_file image] " + ", ".join(parts)
return ToolMessage(
content=[
{"type": "text", "text": metadata},
{
"type": "image",
"base64": base64.standard_b64encode(buffer.getvalue()).decode(),
"mime_type": out_mime,
},
],
name=message.name,
tool_call_id=message.tool_call_id,
status="success",
additional_kwargs={
**message.additional_kwargs,
"read_file_media_type": out_mime,
},
)
def _wrap_tool(tool: BaseTool) -> BaseTool:
"""Rebuild a StructuredTool whose read_file results are post-processed."""
if not isinstance(tool, StructuredTool):
return tool
orig_func = tool.func
orig_coroutine = tool.coroutine
# functools.wraps is load-bearing, not cosmetic: StructuredTool and
# ToolNode inspect signature(fn) for ToolRuntime-annotated params to
# decide which arguments to inject. A bare *args/**kwargs wrapper hides
# `runtime`, so the framework never injects it and the original callable
# raises "missing 1 required positional argument: 'runtime'".
@functools.wraps(orig_func)
def sync_wrapper(*args: Any, **kwargs: Any) -> ToolMessage:
return process_image_tool_message(orig_func(*args, **kwargs))
@functools.wraps(orig_coroutine)
async def async_wrapper(*args: Any, **kwargs: Any) -> ToolMessage:
result = await orig_coroutine(*args, **kwargs)
# Pillow work is blocking; langgraph dev's blockbuster forbids it on
# the event loop.
return await asyncio.to_thread(process_image_tool_message, result)
return StructuredTool.from_function(
name=tool.name,
description=tool.description,
func=sync_wrapper,
coroutine=async_wrapper,
infer_schema=False,
args_schema=tool.args_schema,
)
def install_read_file_image_patch() -> None:
"""Wrap ``FilesystemMiddleware._create_read_file_tool`` (idempotent).
``create_deep_agent`` instantiates ``FilesystemMiddleware`` internally for
the main agent and every sub-agent, so patching the class method covers
all read_file tools in the process.
"""
global _installed
if _installed:
return
from deepagents.middleware.filesystem import FilesystemMiddleware
original = FilesystemMiddleware._create_read_file_tool
def _create_read_file_tool(self: Any) -> BaseTool:
return _wrap_tool(original(self))
FilesystemMiddleware._create_read_file_tool = _create_read_file_tool
_installed = True
+9 -1
View File
@@ -60,7 +60,15 @@ def _build_chat_openai(
kwargs["http_client"] = http_client
if http_async_client is not None:
kwargs["http_async_client"] = http_async_client
return ChatOpenAI(**kwargs)
model = ChatOpenAI(**kwargs)
# OpenAI-compatible endpoints only accept text in tool-role messages;
# this hoists read_file image blocks into a following user message so the
# model actually receives them (otherwise the gateway strips the image
# and the model reports an empty result).
from ..llm.patches import _patch_openai_compat_content
_patch_openai_compat_content(model, hoist_tool_media=True)
return model
def _build_chat_anthropic(
+78 -2
View File
@@ -45,9 +45,11 @@ from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
from EvoScientist.image_gen import service as image_service
from EvoScientist.image_gen.adapters.base import ImageGenError
from EvoScientist.image_gen.config import (
ImageGenerationSettings,
ImageModelEntry,
load_image_generation_settings,
save_image_generation_settings,
)
@@ -57,12 +59,15 @@ from .auth import ActorContext, BffAuthenticator
from .endpoint_policy import EndpointPolicy
from .errors import (
CREDENTIAL_NOT_CONFIGURED,
CREDENTIAL_REJECTED,
FORBIDDEN,
IMAGE_MODEL_NOT_CHAT_MODEL,
INVALID_REQUEST,
MODEL_LIMITS_UNCONFIRMED,
MODEL_NOT_AVAILABLE,
MODEL_NOT_FOUND,
PLATFORM_CONFIG_MISSING,
PROVIDER_UNREACHABLE,
SNAPSHOT_NOT_FOUND,
VALIDATION_FAILED,
ErrorDetail,
@@ -201,8 +206,7 @@ class ImageModelWrite(BaseModel):
provider: Literal["openai", "gemini"] = "openai"
api_key: str = ""
base_url: str = ""
supports_generation: bool = True
supports_edit: bool = True
enabled: bool = True
default_size: str = "1024x1024"
default_quality: str = "auto"
params: dict[str, Any] = Field(default_factory=dict)
@@ -472,6 +476,24 @@ def _error_response(exc: ModelRegistryError, request_id: str) -> JSONResponse:
return JSONResponse(exc.payload(request_id=request_id), status_code=exc.http_status)
def _classify_image_test_error(exc: ImageGenError) -> ModelRegistryError:
"""Map an adapter failure onto the stable section 9.5 codes.
``ImageGenError`` messages are already safe (no secrets, URLs or headers),
so they pass through verbatim.
"""
message = str(exc)
if "HTTP 401" in message or "HTTP 403" in message:
code = CREDENTIAL_REJECTED
elif "HTTP 404" in message:
code = MODEL_NOT_FOUND
elif "timed out" in message or "request failed" in message or "HTTP 5" in message:
code = PROVIDER_UNREACHABLE
else:
code = MODEL_NOT_AVAILABLE
return ModelRegistryError(code, message)
def _validation_error_response(exc: ValidationError, request_id: str) -> JSONResponse:
details = [
ErrorDetail(
@@ -519,6 +541,11 @@ class ModelRegistryHttpApi:
self.put_image_generation,
methods=["PUT"],
),
Route(
"/api/image-generation/test",
self.test_image_generation,
methods=["POST"],
),
Route("/api/runtime-snapshots", self.create_snapshot, methods=["POST"]),
Route(
"/api/runtime-snapshots/{snapshot_id}/bind",
@@ -687,6 +714,20 @@ class ModelRegistryHttpApi:
)
return JSONResponse(response)
async def test_image_generation(self, request: Request) -> Response:
request_id = uuid.uuid4().hex
try:
await self._authenticate(
request, required_scope="model_config:test", require_thread_id=False
)
body = await self._body(request)
response = await self._run_image_model_test(body)
except ModelRegistryError as exc:
return _error_response(exc, request_id)
except ValidationError as exc:
return _validation_error_response(exc, request_id)
return JSONResponse(response)
# --- snapshot API ----------------------------------------------------------
async def create_snapshot(self, request: Request) -> Response:
@@ -918,6 +959,41 @@ class ModelRegistryHttpApi:
save_image_generation_settings(settings)
return ModelRegistryHttpApi._image_generation_response()
@staticmethod
async def _run_image_model_test(body: Any) -> dict[str, Any]:
"""Test a draft image model with one real generation; discards bytes.
A blank ``api_key`` falls back to the stored key for the same model
id, mirroring the PUT "blank keeps stored" rule so an unchanged key
field still tests the effective configuration.
"""
model = ImageModelWrite.model_validate(body)
# Off the event loop, like the GET/PUT workers: config I/O is
# blocking and this route runs in the same ASGI app as the runs.
settings = await asyncio.to_thread(load_image_generation_settings)
api_key = model.api_key
if not api_key:
existing = settings.find_model(model.id)
api_key = existing.api_key if existing is not None else ""
entry = ImageModelEntry(
id=model.id,
name=model.name,
provider=model.provider,
api_key=api_key,
base_url=model.base_url,
enabled=True,
default_size=model.default_size,
default_quality=model.default_quality,
params=model.params,
)
try:
latency_ms = await image_service.test_entry(
entry, timeout=settings.timeout_seconds
)
except ImageGenError as exc:
raise _classify_image_test_error(exc) from exc
return {"ok": True, "latency_ms": latency_ms}
@staticmethod
def _create_snapshot(
services: ApiServices, actor: ActorContext, body: Any
+14 -3
View File
@@ -236,6 +236,15 @@ WRITING_GUIDELINES = """# Writing Guidelines
- Professional, objective tone. Be precise, technical, and concise.
"""
# =============================================================================
# Workspace file references (how replies must cite files)
# =============================================================================
FILE_REFERENCES = """# Referencing Workspace Files
When you mention a workspace file in a reply, always use its exact workspace-relative path WITH the directory prefix (e.g. `artifacts/plot.png`, `data/results.csv`). Two forms are both wrong: a bare filename (`plot.png`), and an absolute host path (`/Users/.../files/artifacts/plot.png`) — shell commands show you real host paths (e.g. via `pwd`), but you MUST convert them back to workspace-relative before referencing them in a reply. To show an image inline, embed it as markdown with that same relative path: `![description](artifacts/plot.png)`. The chat UI resolves workspace-relative paths against the conversation workspace; any other form renders as a broken link the user cannot open.
"""
# =============================================================================
# Shell execution guidelines (rules for the `execute` tool)
# =============================================================================
@@ -410,9 +419,10 @@ def get_system_prompt(
2. :data:`EXPERIMENT_WORKFLOW`
3. :data:`REPORT_TEMPLATE`
4. :data:`WRITING_GUIDELINES`
5. :data:`SHELL_GUIDELINES` (or :data:`SHELL_GUIDELINES_DANGEROUS`)
6. :data:`DELEGATION_STRATEGY`
7. :data:`ASYNC_NOTIFICATIONS`
5. :data:`FILE_REFERENCES`
6. :data:`SHELL_GUIDELINES` (or :data:`SHELL_GUIDELINES_DANGEROUS`)
7. :data:`DELEGATION_STRATEGY`
8. :data:`ASYNC_NOTIFICATIONS`
Runtime context is injected per-turn by
:class:`EvoScientist.middleware.RuntimeContextMiddleware`, so dates and
@@ -439,6 +449,7 @@ def get_system_prompt(
EXPERIMENT_WORKFLOW,
REPORT_TEMPLATE,
WRITING_GUIDELINES,
FILE_REFERENCES,
shell_guidelines,
DELEGATION_STRATEGY,
ASYNC_NOTIFICATIONS,
+38 -5
View File
@@ -6,18 +6,37 @@ resolve ``${ENV_VAR}`` references server-side at call time.
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from langchain.tools import ToolRuntime
from langchain_core.tools import tool
def _workspace() -> Path:
def _resolve_workspace(runtime: ToolRuntime | None) -> Path:
"""Resolve the directory the agent's filesystem writes to.
Scoped conversations must land in the scope files dir — that is what the
WebUI file browser and download API serve. Unscoped (legacy CLI) runs
fall back to the deployment workspace root.
"""
from EvoScientist.workspace_scope import require_scoped_runtime
context = require_scoped_runtime(runtime)
if context is not None:
return context.files_dir
from EvoScientist.paths import _active_workspace
return Path(_active_workspace).resolve()
async def _workspace(runtime: ToolRuntime | None = None) -> Path:
# Scope validation hits the sqlite Registry; run it off the event loop or
# the dev server's blocking-call detector aborts the tool.
return await asyncio.to_thread(_resolve_workspace, runtime)
def _settings_path() -> Path:
from EvoScientist.config.settings import get_config_path
@@ -39,6 +58,12 @@ async def _edit_for_workspace(workspace: Path, **kwargs):
def _json_result(payload: dict) -> str:
if payload.get("ok") and payload.get("paths"):
# Ready-to-use embeds so the agent references the exact
# workspace-relative paths instead of paraphrasing them.
payload["markdown"] = [
f"![{Path(p).stem}]({p})" for p in payload["paths"]
]
return json.dumps(payload, ensure_ascii=False)
@@ -65,13 +90,19 @@ def refresh_image_tool_descriptions() -> None:
restart. Descriptions must never include credentials.
"""
hint = _available_models_hint()
reference_rule = (
" In your reply, embed each saved file with the exact "
"workspace-relative path from the result (the ready-made snippets "
"in the result's markdown field, e.g. ![...](artifacts/example.png))"
" — never drop the directory prefix."
)
generate_image.description = (
"Generate one or more images with a dedicated image model and save "
f"them under artifacts/.{hint}"
f"them under artifacts/.{hint}{reference_rule}"
)
edit_image.description = (
"Edit an existing workspace image with a dedicated image model and "
f"save the result under artifacts/.{hint}"
f"save the result under artifacts/.{hint}{reference_rule}"
)
@@ -84,11 +115,12 @@ async def generate_image(
background: str = "auto",
output_path: str | None = None,
n: int = 1,
runtime: ToolRuntime = None,
) -> str:
"""Generate one or more images with a dedicated image model and save them under artifacts/."""
try:
result = await _generate_for_workspace(
_workspace(),
await _workspace(runtime),
prompt=prompt,
model=model,
size=size,
@@ -111,11 +143,12 @@ async def edit_image(
size: str = "1024x1024",
quality: str = "auto",
output_path: str | None = None,
runtime: ToolRuntime = None,
) -> str:
"""Edit an existing workspace image with a dedicated image model and save the result under artifacts/."""
try:
result = await _edit_for_workspace(
_workspace(),
await _workspace(runtime),
image_path=image_path,
prompt=prompt,
model=model,
+39
View File
@@ -215,6 +215,7 @@ only a transport event, not proof that the LangGraph run has finished.
- [🍪 Examples & Recipes](#-examples--recipes)
- [🔌 MCP Integration](#-mcp-integration)
- [📱 Channels](#-channels)
- [🚢 Releases & Update Checks](#-releases--update-checks)
- [📚 Acknowledgments](#-acknowledgments)
- [🎯 Roadmap](#-ᯓ-roadmap)
- [🌍 Project Roles](#-project-roles)
@@ -677,6 +678,44 @@ The channel can also be started interactively with `/channel` in the CLI session
<p align="right"><a href="#top">🔝Back to top</a></p>
## 🚢 Releases & Update Checks
Releases are published to the self-hosted Gitea instance at `https://git.foksai.com` (repo `ouyangbo/EvoScientist`) and the WebUI shows an amber version badge when a newer release exists.
### Publishing a release
```bash
export GITEA_TOKEN=<token with write:repository scope>
scripts/release.sh 0.2.3 "Release notes text"
```
The script bumps `pyproject.toml`, commits and tags `vX.Y.Z`, pushes the tag, builds the wheel/sdist in a clean temporary worktree (so a dirty working tree never leaks into artifacts), generates `checksums.txt`, creates the Gitea release, and uploads all assets. Passing the version already in `pyproject.toml` reuses the existing tag instead of bumping (used for baseline cuts).
### Update check (backend)
Served by `EvoScientist/update_check.py` and exposed on the langgraph dev server:
- `GET /internal/system/version?force=true` — installed vs latest version. "Latest" is the max semver across Gitea releases **and** tags (the instance's `releases/latest` orders by tag creation date, not semver, so it cannot be trusted alone). Results are cached for 20 minutes; `force=true` bypasses.
- `POST /internal/system/version/download` with optional `{"version": "X.Y.Z"}` — stages the release artifact (wheel → sdist → tarball, in that priority) under `<workspace>/.evoscientist/updates/vX.Y.Z/` and returns a suggested `uv pip install` command. Downloads are host-allowlisted to the Gitea origin (SSRF guard), capped at 200 MB, and sha256-verified against the release's `checksums.txt` when present (mismatch deletes the file). Updates are never auto-applied — the operator runs the install command and restarts the backend.
Both routes require the BFF delegation JWT with the `system:read` scope.
Environment variables:
| Variable | Default | Purpose |
|---|---|---|
| `EVOSCIENTIST_UPDATE_BASE_URL` | `https://git.foksai.com` | Gitea instance URL |
| `EVOSCIENTIST_UPDATE_REPO` | `ouyangbo/EvoScientist` | Repo to check |
| `EVOSCIENTIST_UPDATE_CHECK_DISABLED` | — | Set to `1` to disable outbound checks |
| `EVOSCIENTIST_UPDATE_TOKEN` | — | Optional API token if the instance ever requires auth for reads |
| `GITEA_TOKEN` | — | Only needed by `scripts/release.sh` (writes) |
### WebUI badge
`VersionBadge.tsx` sits in the WebUI header next to the error-log bell and proxies the backend via `/api/system/version` (BFF). When `has_update` is true the badge turns amber with a ping dot; the dropdown offers release notes, a force refresh, and a download button that surfaces the staged file path plus a copyable install command.
<p align="right"><a href="#top">🔝Back to top</a></p>
## 📚 Acknowledgments
This project builds upon the following outstanding open-source works:
+39
View File
@@ -193,6 +193,7 @@ EvoScientist 超越了传统的人在回路(Human-in-the-Loop)模式,采
- [🍪 示例与实践](#-示例与实践)
- [🔌 MCP 集成](#-mcp-集成)
- [📱 渠道接入](#-渠道接入)
- [🚢 发布与更新检查](#-发布与更新检查)
- [📚 致谢](#-致谢)
- [🎯 路线图](#-ᯓ-路线图)
- [🌍 项目角色](#-项目角色)
@@ -647,6 +648,44 @@ channel_enabled: "telegram,slack,feishu,qq"
<p align="right"><a href="#top">🔝回到顶部</a></p>
## 🚢 发布与更新检查
版本发布到自托管 Gitea 实例 `https://git.foksai.com`(仓库 `ouyangbo/EvoScientist`);当有更新版本时,WebUI 顶部的版本角标会变为琥珀色提示。
### 发布新版本
```bash
export GITEA_TOKEN=<具有 write:repository 权限的 token>
scripts/release.sh 0.2.3 "发布说明"
```
脚本会自动:bump `pyproject.toml` 版本、提交并打 `vX.Y.Z` tag、推送 tag、在干净的临时 worktree 中构建 wheel/sdist(本地未提交的改动不会混入发布产物)、生成 `checksums.txt`、创建 Gitea release 并上传全部资产。若传入的版本与 `pyproject.toml` 相同,则复用已有 tag 而不做 bump(用于补发基线版本)。
### 更新检查(后端)
由 `EvoScientist/update_check.py` 实现,挂载在 langgraph dev 服务器上:
- `GET /internal/system/version?force=true` — 对比已安装版本与最新发布版本。"最新"取 Gitea releases **和** tags 的 semver 最大值(该实例的 `releases/latest` 按 tag 创建时间排序而非 semver,不能单独采信)。结果缓存 20 分钟,`force=true` 强制刷新。
- `POST /internal/system/version/download`,可选请求体 `{"version": "X.Y.Z"}` — 按 wheel → sdist → tarball 优先级下载发布产物到 `<workspace>/.evoscientist/updates/vX.Y.Z/`,并返回建议的 `uv pip install` 命令。下载有主机白名单(防 SSRF)、200MB 上限;若 release 附带 `checksums.txt` 则强制 sha256 校验,校验失败立即删除文件。更新不会自动安装——由运维人员手动执行安装命令并重启后端。
两个路由都要求带 `system:read` scope 的 BFF 委托 JWT。
环境变量:
| 变量 | 默认值 | 用途 |
|---|---|---|
| `EVOSCIENTIST_UPDATE_BASE_URL` | `https://git.foksai.com` | Gitea 实例地址 |
| `EVOSCIENTIST_UPDATE_REPO` | `ouyangbo/EvoScientist` | 检查的仓库 |
| `EVOSCIENTIST_UPDATE_CHECK_DISABLED` | — | 设为 `1` 禁用外发检查 |
| `EVOSCIENTIST_UPDATE_TOKEN` | — | 实例将来要求读鉴权时使用的 API token |
| `GITEA_TOKEN` | — | 仅 `scripts/release.sh` 发布时使用(写操作) |
### WebUI 版本角标
`VersionBadge.tsx` 位于 WebUI 顶栏错误铃铛旁,通过 BFF `/api/system/version` 代理后端接口。`has_update` 为真时角标变琥珀色并带红点脉冲;下拉菜单提供发布说明链接、强制刷新,以及下载按钮——下载完成后展示产物路径和可一键复制的安装命令。
<p align="right"><a href="#top">🔝回到顶部</a></p>
## 📚 致谢
本项目基于以下优秀的开源项目构建:
+2
View File
@@ -38,6 +38,8 @@ dependencies = [
# Delegation JWT verification for the model registry HTTP API (7.3);
# previously only a transitive dependency, now used directly.
"PyJWT>=2.8",
# read_file image pipeline (sniff/downsample) in middleware/read_file_images.
"Pillow>=10",
"psutil>=6.0",
"filelock>=3.16",
"lazy-loader>=0.5",
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Start the EvoScientist backend for local development FROM SOURCE.
#
# Always use this script instead of a globally installed `langgraph` binary:
# the uv-tool install runs the stale site-packages copy of EvoScientist,
# which lacks newer HTTP routes (e.g. /api/image-generation → 404).
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORKSPACE_DIR="$REPO_ROOT/EvoScientist/langgraph_dev"
VENV_PYTHON="$REPO_ROOT/.venv/bin/python"
PORT="${PORT:-6174}"
WEBUI_PORT="${WEBUI_PORT:-4716}"
cd "$REPO_ROOT"
export EVOSCIENTIST_WORKSPACE_DIR="$WORKSPACE_DIR"
export EVOSCIENTIST_DEPLOY_MODE=full
export PYTHONIOENCODING=utf-8
export PYTHONUTF8=1
# Must match EVOSCIENTIST_BACKEND_SERVICE_TOKEN in EvoScientist-WebUI/.env.
# Stored at <workspace>/.evoscientist/control/scope-service-token (same file
# `EvoSci deploy` uses), created on first run.
export EVOSCIENTIST_BACKEND_SERVICE_TOKEN="$("$VENV_PYTHON" -c \
"from EvoScientist.scope_registry import get_scope_service_token; print(get_scope_service_token('$WORKSPACE_DIR'))")"
# Usage reporting identities (stable; secrets live under ~/.evoscientist).
eval "$("$VENV_PYTHON" -c \
"from EvoScientist.usage.identity import prepare_usage_environment
for k, v in prepare_usage_environment('$WORKSPACE_DIR', webui_port=$WEBUI_PORT).items():
print(f'export {k}={v}')")"
# langgraph dev resolves the graph/app modules relative to the workspace dir.
# cd here, after the python -c calls above: running them with the workspace as
# cwd would put langgraph_dev/ on sys.path, where http.py shadows the stdlib
# http package and breaks the httpx import chain.
cd "$WORKSPACE_DIR"
exec "$REPO_ROOT/.venv/bin/langgraph" dev \
--config "$WORKSPACE_DIR/langgraph.json" \
--port "$PORT" \
--n-jobs-per-worker 10 \
--no-browser \
--no-reload
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Release EvoScientist to git.foksai.com.
#
# Usage:
# scripts/release.sh 0.2.3 "Release notes text" # bump pyproject, tag, publish
# scripts/release.sh 0.2.2 "Release notes text" # version == pyproject: reuse existing tag
#
# Requires: GITEA_TOKEN (write:repository), uv, shasum, jq, curl.
# Optional: GITEA_BASE_URL (default https://git.foksai.com), GITEA_REPO (default ouyangbo/EvoScientist).
#
# The wheel/sdist are built in a temporary git worktree at the tag so dirty
# working trees never leak into published artifacts.
set -euo pipefail
VERSION="${1:?usage: release.sh <version> <notes>}"
NOTES="${2:?usage: release.sh <version> <notes>}"
TAG="v${VERSION}"
BASE="${GITEA_BASE_URL:-https://git.foksai.com}"
REPO="${GITEA_REPO:-ouyangbo/EvoScientist}"
: "${GITEA_TOKEN:?export GITEA_TOKEN with write:repository scope}"
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"
PYPROJECT_VERSION="$(grep -m1 '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')"
if [[ "$VERSION" != "$PYPROJECT_VERSION" ]]; then
echo "==> bump pyproject $PYPROJECT_VERSION -> $VERSION"
sed -i '' "s/^version = \"$PYPROJECT_VERSION\"/version = \"$VERSION\"/" pyproject.toml
uv lock
git add pyproject.toml uv.lock
git commit -m "chore(release): $TAG"
git tag "$TAG"
else
git rev-parse --verify --quiet "refs/tags/$TAG" >/dev/null || {
echo "error: pyproject is already $VERSION but tag $TAG does not exist" >&2
exit 1
}
echo "==> reusing existing tag $TAG"
fi
echo "==> pushing tag $TAG"
git push origin "$TAG"
WORKTREE="$(mktemp -d)/build"
trap 'git worktree remove --force "$WORKTREE" 2>/dev/null || true' EXIT
echo "==> building from clean worktree at $TAG"
git worktree add --detach "$WORKTREE" "$TAG"
( cd "$WORKTREE" && uv build )
DIST="$WORKTREE/dist"
( cd "$DIST" && shasum -a 256 ./*.whl ./*.tar.gz > checksums.txt )
echo "==> creating release $TAG"
RELEASE_ID="$(curl -sS -f -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg tag "$TAG" --arg body "$NOTES" \
'{tag_name: $tag, name: "Release \($tag)", body: $body}')" \
"$BASE/api/v1/repos/$REPO/releases" | jq -r .id)"
echo " release id: $RELEASE_ID"
for f in "$DIST"/*.whl "$DIST"/*.tar.gz "$DIST/checksums.txt"; do
echo "==> uploading $(basename "$f")"
curl -sS -f -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H 'Content-Type: application/octet-stream' \
--data-binary "@$f" \
"$BASE/api/v1/repos/$REPO/releases/$RELEASE_ID/assets?name=$(basename "$f")" \
| jq -r '.browser_download_url'
done
echo "==> verify"
curl -sS -f "$BASE/api/v1/repos/$REPO/releases/latest" | jq -r '.tag_name'
echo "done: $TAG published"
+65
View File
@@ -369,6 +369,71 @@ class TestConvertVirtualPaths:
assert result == 'echo "/hi"'
# === quoted workspace paths (workspace_root disambiguation) ===
class TestQuotedWorkspacePaths:
def test_quoted_uploaded_file_rewritten(self, tmp_path):
"""The WebUI references uploaded files as ``/<name>``; quoted inside
a heredoc script (``p='/name.jpg'``) that would hit the host root.
When the parent (the workspace root) exists, rewrite to
``./name.jpg``."""
command = "python - <<'PY'\np='/微信图片_20260725131417_86_3.jpg'\nPY"
result = convert_virtual_paths_in_command(
command, workspace_root=str(tmp_path)
)
assert result == "python - <<'PY'\np='./微信图片_20260725131417_86_3.jpg'\nPY"
def test_quoted_new_file_with_existing_parent_rewritten(self, tmp_path):
"""Output paths don't exist yet; the parent's existence is what
disambiguates them from system paths."""
(tmp_path / "artifacts").mkdir()
command = "python - <<'PY'\nopen('/artifacts/out.png', 'wb')\nPY"
result = convert_virtual_paths_in_command(
command, workspace_root=str(tmp_path)
)
assert result == "python - <<'PY'\nopen('./artifacts/out.png', 'wb')\nPY"
def test_quoted_system_path_left_alone(self, tmp_path):
"""``/etc/hosts`` has no ``etc/`` under the workspace → untouched."""
result = convert_virtual_paths_in_command(
'cat "/etc/hosts"',
workspace_root=str(tmp_path),
)
assert result == 'cat "/etc/hosts"'
def test_quoted_traversal_left_alone(self, tmp_path):
result = convert_virtual_paths_in_command(
'cat "/../../etc/passwd"',
workspace_root=str(tmp_path),
)
assert result == 'cat "/../../etc/passwd"'
def test_quoted_workspace_root_itself(self, tmp_path):
result = convert_virtual_paths_in_command(
'cd "/"',
workspace_root=str(tmp_path),
)
assert result == "cd ."
def test_no_workspace_root_keeps_legacy_behavior(self, tmp_path):
"""Without *workspace_root* nothing about quoted paths changes."""
(tmp_path / "artifacts").mkdir()
result = convert_virtual_paths_in_command(
'cat "/artifacts/out.png"',
)
assert result == 'cat "/artifacts/out.png"'
def test_prepare_sandbox_command_rewrites_quoted_upload(self, tmp_path):
"""End-to-end: the sandbox entry point passes its cwd as
*workspace_root*, so quoted upload references resolve."""
prepared, error = prepare_sandbox_command(
"python - <<'PY'\np='/photo.jpg'\nPY", tmp_path
)
assert error is None
assert prepared == "python - <<'PY'\np='./photo.jpg'\nPY"
# === tier-aware virtual mounts (/skills/, /memories/) ===
+2 -2
View File
@@ -37,7 +37,7 @@ image_generation:
- id: imagen-4.0-generate-001
provider: gemini
api_key: ${TEST_IMG_KEY}
supports_edit: false
enabled: false
""",
)
settings = load_image_generation_settings(config_path=path)
@@ -50,7 +50,7 @@ image_generation:
assert openai_entry.display_name() == "GPT Image 2"
gemini_entry = settings.models[1]
assert gemini_entry.provider == "gemini"
assert gemini_entry.supports_edit is False
assert gemini_entry.enabled is False
assert gemini_entry.display_name() == "imagen-4.0-generate-001"
+59 -3
View File
@@ -48,7 +48,7 @@ image_generation:
- id: imagen-4.0-generate-001
provider: gemini
api_key: sk-y
supports_edit: false
enabled: false
""",
encoding="utf-8",
)
@@ -155,11 +155,67 @@ async def test_edit_rejects_bad_input(tmp_path):
)
async def test_edit_blocked_when_model_lacks_support(tmp_path):
with pytest.raises(ImageGenError, match="edit"):
async def test_disabled_model_rejected(tmp_path):
with pytest.raises(ImageGenError, match="disabled"):
await service.generate_for_workspace(
tmp_path, prompt="x", model="imagen-4.0-generate-001"
)
with pytest.raises(ImageGenError, match="disabled"):
await service.edit_for_workspace(
tmp_path,
image_path="artifacts/whatever.png",
prompt="x",
model="imagen-4.0-generate-001",
)
async def test_disabled_default_falls_back_to_first_enabled(tmp_path, monkeypatch):
config = tmp_path / "config.yaml"
config.write_text(
"""
image_generation:
default_model: gpt-image-2
models:
- id: gpt-image-2
provider: openai
api_key: sk-x
enabled: false
- id: imagen-4.0-generate-001
provider: gemini
api_key: sk-y
""",
encoding="utf-8",
)
monkeypatch.setattr(service, "_config_path", lambda: config)
result = await service.generate_for_workspace(tmp_path, prompt="x")
assert result["model"] == "imagen-4.0-generate-001"
async def test_all_models_disabled_rejected(tmp_path, monkeypatch):
config = tmp_path / "config.yaml"
config.write_text(
"""
image_generation:
models:
- id: gpt-image-2
provider: openai
api_key: sk-x
enabled: false
""",
encoding="utf-8",
)
monkeypatch.setattr(service, "_config_path", lambda: config)
with pytest.raises(ImageGenError, match="no enabled image models"):
await service.generate_for_workspace(tmp_path, prompt="x")
async def test_test_entry_generates_and_reports_latency():
from EvoScientist.image_gen.config import ImageModelEntry
entry = ImageModelEntry(id="gpt-image-2", provider="openai", api_key="sk-x")
latency = await service.test_entry(entry, timeout=30.0)
assert latency >= 0
adapter = _FakeAdapter.instances[-1]
assert adapter.calls[0]["kind"] == "generate"
assert adapter.calls[0]["n"] == 1
assert adapter.calls[0]["size"] == "1024x1024"
+121 -2
View File
@@ -16,6 +16,8 @@ from starlette.applications import Starlette
from starlette.testclient import TestClient
from EvoScientist.image_gen import config as image_config
from EvoScientist.image_gen import service as image_service
from EvoScientist.image_gen.adapters.base import ImageGenError
from EvoScientist.image_gen.config import (
ImageGenerationSettings,
ImageModelEntry,
@@ -123,8 +125,7 @@ def _settings_payload(**overrides):
"provider": "openai",
"api_key": "sk-live-9876abcd",
"base_url": "",
"supports_generation": True,
"supports_edit": True,
"enabled": True,
"default_size": "1024x1024",
"default_quality": "auto",
"params": {},
@@ -238,3 +239,121 @@ def test_get_corrupt_section_returns_422_envelope(client, config_path):
body = response.json()
assert body["code"] == "VALIDATION_FAILED"
assert "midjourney" not in json.dumps(body)
# --- POST /api/image-generation/test -----------------------------------------
class _FakeImageAdapter:
"""Records the entry it was built with; can be primed to fail."""
instances: list["_FakeImageAdapter"] = []
failure: str | None = None
def __init__(self, entry, *, timeout=120.0):
self.entry = entry
self.timeout = timeout
_FakeImageAdapter.instances.append(self)
async def generate(self, **kwargs):
if _FakeImageAdapter.failure is not None:
raise ImageGenError(_FakeImageAdapter.failure)
return [b"\x89PNG\r\n\x1a\nfake"]
async def edit(self, **kwargs): # pragma: no cover - not used by tests
return [b"\x89PNG\r\n\x1a\nfake"]
@pytest.fixture
def fake_image_adapter(monkeypatch):
_FakeImageAdapter.instances = []
_FakeImageAdapter.failure = None
monkeypatch.setattr(
image_service,
"ADAPTER_CLASSES",
{"openai": _FakeImageAdapter, "gemini": _FakeImageAdapter},
)
return _FakeImageAdapter
def _test_headers():
return _headers(["model_config:test"])
def _test_payload(**overrides):
payload = _settings_payload()["models"][0]
payload.update(overrides)
return payload
def test_image_test_success(client, fake_image_adapter):
response = client.post(
"/api/image-generation/test",
headers=_test_headers(),
json=_test_payload(),
)
assert response.status_code == 200
body = response.json()
assert body["ok"] is True
assert body["latency_ms"] >= 0
entry = fake_image_adapter.instances[0].entry
assert entry.id == "gpt-image-2"
assert entry.api_key == "sk-live-9876abcd"
def test_image_test_blank_key_falls_back_to_stored(client, fake_image_adapter):
client.put(
"/api/image-generation", headers=_admin_headers(), json=_settings_payload()
)
response = client.post(
"/api/image-generation/test",
headers=_test_headers(),
json=_test_payload(api_key="", name="Renamed draft"),
)
assert response.status_code == 200
entry = fake_image_adapter.instances[0].entry
assert entry.api_key == "sk-live-9876abcd"
def test_image_test_credential_rejected(client, fake_image_adapter):
fake_image_adapter.failure = "Image provider returned HTTP 401: bad key"
response = client.post(
"/api/image-generation/test",
headers=_test_headers(),
json=_test_payload(),
)
assert response.status_code == 422
body = response.json()
assert body["code"] == "CREDENTIAL_REJECTED"
assert "bad key" in body["message"]
def test_image_test_provider_unreachable(client, fake_image_adapter):
fake_image_adapter.failure = "Image provider request failed: ConnectError"
response = client.post(
"/api/image-generation/test",
headers=_test_headers(),
json=_test_payload(),
)
assert response.status_code == 422
assert response.json()["code"] == "PROVIDER_UNREACHABLE"
def test_image_test_model_not_found(client, fake_image_adapter):
fake_image_adapter.failure = "Image provider returned HTTP 404: no such model"
response = client.post(
"/api/image-generation/test",
headers=_test_headers(),
json=_test_payload(),
)
assert response.status_code == 404
assert response.json()["code"] == "MODEL_NOT_FOUND"
def test_image_test_requires_test_scope(client, fake_image_adapter):
response = client.post(
"/api/image-generation/test",
headers=_headers(["model_config:write"]),
json=_test_payload(),
)
assert response.status_code == 403
+7 -1
View File
@@ -30,9 +30,12 @@ def fake_service(monkeypatch, tmp_path):
"size": kwargs.get("size", "1024x1024"),
}
async def fake_workspace(runtime=None):
return tmp_path
monkeypatch.setattr(image_tools, "_generate_for_workspace", fake_generate)
monkeypatch.setattr(image_tools, "_edit_for_workspace", fake_edit)
monkeypatch.setattr(image_tools, "_workspace", lambda: tmp_path)
monkeypatch.setattr(image_tools, "_workspace", fake_workspace)
async def test_generate_image_success():
@@ -41,6 +44,7 @@ async def test_generate_image_success():
)
assert result["ok"] is True
assert result["paths"] == ["artifacts/generated_1.png"]
assert result["markdown"] == ["![generated_1](artifacts/generated_1.png)"]
async def test_edit_image_success():
@@ -84,6 +88,8 @@ image_generation:
assert "GPT Image 2" in image_tools.generate_image.description
assert "Default: gpt-image-2" in image_tools.generate_image.description
assert "sk-x" not in image_tools.generate_image.description
assert "workspace-relative path" in image_tools.generate_image.description
assert "workspace-relative path" in image_tools.edit_image.description
def test_tools_package_exports():
+149
View File
@@ -0,0 +1,149 @@
"""Prove the image tool path makes no blocking calls on the event loop.
The langgraph dev server activates blockbuster during runs; any sync file,
sqlite or socket call inside the async tool aborts it. These tests run the
full tool chain (scope resolution -> config load -> adapter -> save) under an
active BlockBuster, once against a scoped conversation and once unscoped.
"""
import json
from types import SimpleNamespace
import pytest
from blockbuster import BlockBuster
import EvoScientist.scope_registry as registry_module
import EvoScientist.workspace_scope as workspace_scope_module
from EvoScientist import paths
from EvoScientist.image_gen import config as image_config
from EvoScientist.image_gen import service
from EvoScientist.tools import image as image_tools
from EvoScientist.workspace_scope import provision_conversation_scope
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"blockbuster"
class _FakeAdapter:
def __init__(self, entry, *, client=None, timeout=120.0):
self.entry = entry
async def generate(self, **kwargs):
return [PNG_BYTES]
async def edit(self, **kwargs):
return [PNG_BYTES]
@pytest.fixture
def scoped_env(tmp_path, monkeypatch):
monkeypatch.setattr(paths, "WORKSPACE_ROOT", tmp_path)
monkeypatch.setenv("EVOSCIENTIST_WORKSPACE_ISOLATION", "optional")
registry_module._registry_cache.clear()
record = provision_conversation_scope("thread-a", deployment_id="deployment-a")
config = tmp_path / "config.yaml"
config.write_text(
"image_generation:\n"
" models:\n"
" - id: gpt-image-2\n"
" provider: openai\n"
" api_key: sk-x\n",
encoding="utf-8",
)
monkeypatch.setattr(service, "_config_path", lambda: config)
monkeypatch.setattr(
service, "ADAPTER_CLASSES", {"openai": _FakeAdapter, "gemini": _FakeAdapter}
)
return record
def _blocker():
return BlockBuster(
scanned_modules=[
image_tools,
service,
workspace_scope_module,
registry_module,
image_config,
]
)
async def test_scoped_generate_makes_no_blocking_calls(scoped_env):
record = scoped_env
configurable = {
"thread_id": "thread-a",
"workspace_scope_id": record.scope_id,
"workspace_scope_owner_id": record.primary_owner_id,
"workspace_scope_revision": record.revision,
"workspace_deployment_id": "deployment-a",
}
blocker = _blocker()
blocker.activate()
try:
result = json.loads(
await image_tools.generate_image.ainvoke(
{"prompt": "a cat"}, config={"configurable": configurable}
)
)
finally:
blocker.deactivate()
assert result["ok"] is True, result
saved = record_files_dir(record) / result["paths"][0]
assert saved.is_file()
assert saved.read_bytes() == PNG_BYTES
async def test_unscoped_generate_makes_no_blocking_calls(scoped_env, monkeypatch):
# No scope identifiers in the config: falls back to _active_workspace.
monkeypatch.setattr(paths, "_active_workspace", paths.WORKSPACE_ROOT)
blocker = _blocker()
blocker.activate()
try:
result = json.loads(
await image_tools.generate_image.ainvoke(
{"prompt": "a cat"}, config={"configurable": {}}
)
)
finally:
blocker.deactivate()
assert result["ok"] is True, result
saved = paths.WORKSPACE_ROOT / result["paths"][0]
assert saved.is_file()
async def test_scoped_edit_makes_no_blocking_calls(scoped_env):
record = scoped_env
files_dir = record_files_dir(record)
(files_dir / "artifacts").mkdir(parents=True)
(files_dir / "artifacts" / "src.png").write_bytes(PNG_BYTES)
configurable = {
"thread_id": "thread-a",
"workspace_scope_id": record.scope_id,
"workspace_scope_owner_id": record.primary_owner_id,
"workspace_scope_revision": record.revision,
"workspace_deployment_id": "deployment-a",
}
blocker = _blocker()
blocker.activate()
try:
result = json.loads(
await image_tools.edit_image.ainvoke(
{"image_path": "artifacts/src.png", "prompt": "make blue"},
config={"configurable": configurable},
)
)
finally:
blocker.deactivate()
assert result["ok"] is True, result
assert (files_dir / result["paths"][0]).is_file()
def record_files_dir(record):
return (
paths.WORKSPACE_ROOT
/ ".evoscientist"
/ "conversations"
/ record.scope_id
/ "files"
)
+248
View File
@@ -115,6 +115,7 @@ class TestSnapshotMessageBudget:
assert budget.hard_tokens == int(message_budget * 0.90)
assert budget.soft_tokens == int(budget.hard_tokens * 0.70)
assert budget.keep_tokens == int(budget.hard_tokens * 0.35)
assert budget.reserved_tokens == 4096
def test_tools_and_attachments_deduct_their_reserves(self, store):
snapshot = make_snapshot(store)
@@ -126,6 +127,8 @@ class TestSnapshotMessageBudget:
)
assert tools_only.hard_tokens == int((1015808 - 4096 - 8192) * 0.90)
assert full.hard_tokens == int((1015808 - 4096 - 8192 - 4096) * 0.90)
assert tools_only.reserved_tokens == 4096 + 8192
assert full.reserved_tokens == 4096 + 8192 + 4096
class TestSnapshotModeMiddleware:
def test_budget_uses_frozen_reserves_not_model_profile(self, store, runtime):
@@ -357,3 +360,248 @@ def test_active_budget_outside_model_call_fails_loudly(runtime):
)
with pytest.raises(RuntimeError, match="outside a model call"):
middleware._active_budget()
# =============================================================================
# Compression token stats on the summarization event
# =============================================================================
class TestCompressionTokenStats:
"""A freshly created ``_summarization_event`` records the estimated
prompt size before and after compaction plus the active budget, so the
WebUI can show what compression actually did (character estimates — the
provider-measured sizes keep flowing through the usage pipeline)."""
def _event_response(self):
event = {
"cutoff_index": 1,
"summary_message": HumanMessage(content="summary"),
"file_path": None,
}
return SimpleNamespace(
command=SimpleNamespace(update={"_summarization_event": event})
)
@staticmethod
def _fresh_event_response():
return TestCompressionTokenStats._event_response(None)
def test_augment_attaches_estimates_and_budget(self, store, runtime):
from EvoScientist.middleware.message_budget import (
_LAST_TOTAL_TOKENS,
_augment_summarization_event,
)
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), has_tools=False, runtime=runtime
)
snapshot = make_snapshot(store)
with _patched_config(_configurable_for(snapshot)):
budget = middleware._budget_for_request(_request([HumanMessage("hi")]))
token = _LAST_TOTAL_TOKENS.set(5_000)
try:
response = self._event_response()
_augment_summarization_event(response, budget, [1_250])
finally:
_LAST_TOTAL_TOKENS.reset(token)
event = response.command.update["_summarization_event"]
assert event["estimated_tokens_before"] == 5_000
assert event["estimated_tokens_after"] == 1_250
assert event["budget"] == {
"hard_tokens": budget.hard_tokens,
"soft_tokens": budget.soft_tokens,
"keep_tokens": budget.keep_tokens,
}
def test_augment_ignores_responses_without_a_new_event(self, store, runtime):
from EvoScientist.middleware.message_budget import (
_augment_summarization_event,
)
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), has_tools=False, runtime=runtime
)
snapshot = make_snapshot(store)
with _patched_config(_configurable_for(snapshot)):
budget = middleware._budget_for_request(_request([HumanMessage("hi")]))
plain = SimpleNamespace()
_augment_summarization_event(plain, budget, [10])
assert not hasattr(plain, "command")
no_event = SimpleNamespace(command=SimpleNamespace(update={"messages": []}))
_augment_summarization_event(no_event, budget, [10])
assert "_summarization_event" not in no_event.command.update
def test_should_summarize_stashes_total_tokens(self, store, runtime):
from EvoScientist.middleware.message_budget import (
_ACTIVE_BUDGET,
_LAST_TOTAL_TOKENS,
)
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), has_tools=False, runtime=runtime
)
snapshot = make_snapshot(store)
messages = [HumanMessage(content="a" * 400)]
with _patched_config(_configurable_for(snapshot)):
token = _ACTIVE_BUDGET.set(
middleware._budget_for_request(_request(messages))
)
try:
middleware._should_summarize(messages, 123_456)
assert _LAST_TOTAL_TOKENS.get() == 123_456
finally:
_ACTIVE_BUDGET.reset(token)
def test_wrap_model_call_counts_final_prompt_and_augments_event(
self, store, runtime
):
from deepagents.middleware.summarization import SummarizationMiddleware
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), has_tools=False, runtime=runtime
)
snapshot = make_snapshot(store)
compressed = [
HumanMessage(content="summary"),
HumanMessage(content="kept tail"),
]
response_holder = {}
def fake_base(_self, _request, handler):
# The base class's last handler call carries the exact prompt sent
# to the model after compaction.
modified = SimpleNamespace(messages=compressed, tools=[])
response_holder["handler_result"] = handler(modified)
return TestCompressionTokenStats._fresh_event_response()
with (
_patched_config(_configurable_for(snapshot)),
patch.object(SummarizationMiddleware, "wrap_model_call", fake_base),
):
response = middleware.wrap_model_call(
SimpleNamespace(messages=[HumanMessage("x" * 400)], tools=[]),
MagicMock(return_value="model-response"),
)
assert response_holder["handler_result"] == "model-response"
event = response.command.update["_summarization_event"]
assert event["estimated_tokens_after"] == count_message_text_tokens(
compressed
)
assert event["budget"]["hard_tokens"] > 0
async def test_awrap_model_call_counts_final_prompt_and_augments_event(
self, store, runtime
):
from deepagents.middleware.summarization import SummarizationMiddleware
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), has_tools=False, runtime=runtime
)
snapshot = make_snapshot(store)
compressed = [HumanMessage(content="summary")]
async def fake_base(_self, _request, handler):
modified = SimpleNamespace(messages=compressed, tools=[])
await handler(modified)
return TestCompressionTokenStats._fresh_event_response()
async def handler(_request):
return "model-response"
with (
_patched_config(_configurable_for(snapshot)),
patch.object(SummarizationMiddleware, "awrap_model_call", fake_base),
):
response = await middleware.awrap_model_call(
SimpleNamespace(messages=[HumanMessage("x" * 400)], tools=[]),
handler,
)
event = response.command.update["_summarization_event"]
assert event["estimated_tokens_after"] == count_message_text_tokens(
compressed
)
class TestContextUsageEmission:
"""Every model call streams its prompt size on the ``custom`` stream
mode so the UI can render a live context-occupancy indicator."""
def _budget(self, middleware, snapshot):
with _patched_config(_configurable_for(snapshot)):
return middleware._budget_for_request(_request([HumanMessage("hi")]))
def test_emit_writes_payload_with_writer(self, store, runtime):
from EvoScientist.middleware.message_budget import _emit_context_usage
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), has_tools=False, runtime=runtime
)
budget = self._budget(middleware, make_snapshot(store))
written = []
with patch(
"langgraph.config.get_stream_writer",
lambda: written.append,
):
_emit_context_usage(budget, 123)
assert written == [
{
"type": "evoscientist_context_usage",
"used_tokens": 123,
"reserved_tokens": budget.reserved_tokens,
"input_limit": budget.input_limit,
"hard_tokens": budget.hard_tokens,
"soft_tokens": budget.soft_tokens,
}
]
def test_emit_skips_outside_runnable_context(self, store, runtime):
from EvoScientist.middleware.message_budget import _emit_context_usage
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), has_tools=False, runtime=runtime
)
budget = self._budget(middleware, make_snapshot(store))
with patch(
"langgraph.config.get_stream_writer",
side_effect=RuntimeError("no runnable context"),
):
_emit_context_usage(budget, 123) # must not raise
def test_wrap_model_call_emits_final_prompt_size(self, store, runtime):
from deepagents.middleware.summarization import SummarizationMiddleware
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), has_tools=False, runtime=runtime
)
snapshot = make_snapshot(store)
sent = [HumanMessage(content="final prompt")]
written = []
def fake_base(_self, _request, handler):
handler(SimpleNamespace(messages=sent, tools=[]))
return TestCompressionTokenStats._fresh_event_response()
with (
_patched_config(_configurable_for(snapshot)),
patch.object(SummarizationMiddleware, "wrap_model_call", fake_base),
patch("langgraph.config.get_stream_writer", lambda: written.append),
):
middleware.wrap_model_call(
SimpleNamespace(messages=[HumanMessage("x" * 400)], tools=[]),
MagicMock(return_value="model-response"),
)
assert [event["used_tokens"] for event in written] == [
count_message_text_tokens(sent)
]
assert all(event["type"] == "evoscientist_context_usage" for event in written)
+165
View File
@@ -51,6 +51,7 @@ from EvoScientist.memory.observations import (
)
from EvoScientist.memory.types import ObservationRelation
from EvoScientist.middleware import memory_lifecycle
from EvoScientist.model_registry.schemas import ModelRef
def _read_memory_document(path) -> tuple[dict[str, Any], str]:
@@ -1789,6 +1790,7 @@ def test_memory_worker_run_payload_use_server_thread_id_and_source_metadata(
"_worker_workspace_dir",
lambda _workspace_dir: "/tmp/ws",
)
monkeypatch.setattr(memory_launch, "_worker_snapshot_id", lambda *a: None)
trajectory: list[source_context.CompactMessage] = [
{"role": "human", "content": "hi"}
]
@@ -1836,6 +1838,7 @@ def test_memory_worker_propagates_optional_usage_turn(monkeypatch):
"_worker_workspace_dir",
lambda _workspace_dir: "/tmp/ws",
)
monkeypatch.setattr(memory_launch, "_worker_snapshot_id", lambda *a: None)
context = _memory_source_context(
memory_dir="/memories",
workspace_dir="/active/workspace",
@@ -1852,6 +1855,168 @@ def test_memory_worker_propagates_optional_usage_turn(monkeypatch):
)
class TestWorkerSnapshotInheritance:
def test_inherits_source_thread_selection(self, monkeypatch, tmp_path):
from EvoScientist.model_registry.runtime import SnapshotRuntime
from tests.registry_fixtures import ZHIPU_REF, make_active_store
runtime = SnapshotRuntime(make_active_store(tmp_path / "model-runtime"))
monkeypatch.setattr(
memory_launch,
"_source_thread_model_selection",
lambda _session_id: (ZHIPU_REF, None, 3),
)
monkeypatch.setattr(
"EvoScientist.model_registry.runtime.get_snapshot_runtime",
lambda: runtime,
)
context = _memory_source_context(
memory_dir="/memories",
workspace_dir="/active/workspace",
)
kwargs = memory_launch._memory_worker_run_payload(
context=context,
thread_id="worker-thread",
)
snapshot_id = kwargs["config"]["configurable"].get("runtime_snapshot_id")
assert snapshot_id is not None
snapshot = runtime.get_snapshot(
snapshot_id,
deployment_id=runtime.local_deployment_id,
thread_id="worker-thread",
)
assert snapshot.payload.primary.model_ref == ZHIPU_REF
assert snapshot.payload.model_selection_revision == 3
def test_falls_back_when_source_has_no_selection(self, monkeypatch):
monkeypatch.setattr(
memory_launch,
"_source_thread_model_selection",
lambda _session_id: None,
)
context = _memory_source_context(
memory_dir="/memories",
workspace_dir="/active/workspace",
)
kwargs = memory_launch._memory_worker_run_payload(
context=context,
thread_id="worker-thread",
)
assert "runtime_snapshot_id" not in kwargs["config"]["configurable"]
def test_falls_back_when_snapshot_creation_fails(self, monkeypatch):
monkeypatch.setattr(
memory_launch,
"_source_thread_model_selection",
lambda _session_id: (
ModelRef(provider_id="zhipu-glm", model_key="glm-5.2"),
None,
0,
),
)
monkeypatch.setattr(
"EvoScientist.model_registry.runtime.get_snapshot_runtime",
lambda: (_ for _ in ()).throw(RuntimeError("store unavailable")),
)
context = _memory_source_context(
memory_dir="/memories",
workspace_dir="/active/workspace",
)
kwargs = memory_launch._memory_worker_run_payload(
context=context,
thread_id="worker-thread",
)
assert "runtime_snapshot_id" not in kwargs["config"]["configurable"]
class TestSourceThreadModelSelection:
def _patch_thread(self, monkeypatch, thread: dict):
client = SimpleNamespace(
threads=SimpleNamespace(get=lambda _thread_id: thread)
)
monkeypatch.setattr("langgraph_sdk.get_sync_client", lambda **_: client)
def test_parses_explicit_selection_and_tolerates_legacy_auxiliary(
self, monkeypatch
):
self._patch_thread(
monkeypatch,
{
"metadata": {
"model_selection": {
"primary": {
"provider_id": "zhipu-glm",
"model_key": "glm-5.2",
},
"auxiliary": {
"provider_id": "zhipu-glm",
"model_key": "glm-5.2",
},
"reasoning_effort": "high",
},
"model_selection_revision": 7,
}
},
)
selection = memory_launch._source_thread_model_selection("thread-1")
assert selection == (
ModelRef(provider_id="zhipu-glm", model_key="glm-5.2"),
"high",
7,
)
def test_returns_none_for_inherit_selection(self, monkeypatch):
self._patch_thread(
monkeypatch,
{"metadata": {"model_selection": "inherit"}},
)
assert memory_launch._source_thread_model_selection("thread-1") is None
def test_returns_none_for_missing_or_malformed_metadata(self, monkeypatch):
self._patch_thread(monkeypatch, {"metadata": {}})
assert memory_launch._source_thread_model_selection("thread-1") is None
self._patch_thread(
monkeypatch,
{
"metadata": {
"model_selection": {"primary": {"provider_id": "zhipu-glm"}}
}
},
)
assert memory_launch._source_thread_model_selection("thread-1") is None
def test_drops_unknown_reasoning_effort(self, monkeypatch):
self._patch_thread(
monkeypatch,
{
"metadata": {
"model_selection": {
"primary": {
"provider_id": "zhipu-glm",
"model_key": "glm-5.2",
},
"reasoning_effort": "extreme",
}
}
},
)
selection = memory_launch._source_thread_model_selection("thread-1")
assert selection is not None
assert selection[1] is None
def test_memory_worker_finish_launches_linker_for_new_observations(
tmp_path,
):
+345
View File
@@ -0,0 +1,345 @@
"""Tests for the read_file image post-processing pipeline."""
from __future__ import annotations
import base64
import io
import pytest
from langchain_core.messages import ToolMessage
from PIL import Image
from EvoScientist.middleware.read_file_images import (
MAX_IMAGE_EDGE,
install_read_file_image_patch,
process_image_tool_message,
)
def _b64_image(fmt: str, size: tuple[int, int], *, mode: str = "RGB") -> str:
image = Image.new(mode, size, color=(120, 30, 200))
buffer = io.BytesIO()
image.save(buffer, fmt)
return base64.standard_b64encode(buffer.getvalue()).decode()
def _image_message(
b64: str,
*,
media_type: str = "image/png",
path: str = "/artifacts/x.png",
block_type: str = "image",
) -> ToolMessage:
return ToolMessage(
content=[{"type": block_type, "base64": b64, "mime_type": media_type}],
name="read_file",
tool_call_id="call-1",
status="success",
additional_kwargs={"read_file_path": path, "read_file_media_type": media_type},
)
def _decode_block(message: ToolMessage) -> tuple[dict, Image.Image]:
block = message.content[-1]
raw = base64.standard_b64decode(block["base64"])
image = Image.open(io.BytesIO(raw))
image.load()
return block, image
class TestPassthrough:
def test_small_correct_type_keeps_bytes_adds_metadata(self):
b64 = _b64_image("PNG", (100, 80))
message = _image_message(b64)
result = process_image_tool_message(message)
assert result is not message
assert result.status == "success"
meta, block = result.content
assert meta["type"] == "text"
assert "100x80" in meta["text"]
assert "PNG" in meta["text"]
assert "downsampled" not in meta["text"]
# Original bytes preserved — no re-encode.
assert block["base64"] == b64
assert block["mime_type"] == "image/png"
assert result.additional_kwargs["read_file_media_type"] == "image/png"
def test_svg_untouched(self):
message = _image_message(
base64.standard_b64encode(b"<svg/>").decode(),
media_type="image/svg+xml",
path="/artifacts/icon.svg",
)
assert process_image_tool_message(message) is message
def test_text_result_untouched(self):
message = ToolMessage(
content="1 | hello",
name="read_file",
tool_call_id="call-1",
status="success",
)
assert process_image_tool_message(message) is message
def test_error_result_untouched(self):
message = ToolMessage(
content="Error: not found",
name="read_file",
tool_call_id="call-1",
status="error",
)
assert process_image_tool_message(message) is message
def test_non_image_binary_untouched(self):
message = _image_message(
base64.standard_b64encode(b"%PDF-1.4 fake").decode(),
media_type="application/pdf",
path="/artifacts/paper.pdf",
block_type="file",
)
assert process_image_tool_message(message) is message
class TestDownsample:
def test_oversized_rgb_becomes_jpeg_with_metadata(self):
message = _image_message(_b64_image("PNG", (3000, 1200)))
result = process_image_tool_message(message)
assert result.status == "success"
assert len(result.content) == 2
meta, block = result.content
assert meta["type"] == "text"
assert "3000x1200" in meta["text"]
assert "PNG" in meta["text"]
assert "downsampled" in meta["text"]
assert str(MAX_IMAGE_EDGE) in meta["text"]
assert block["type"] == "image"
assert block["mime_type"] == "image/jpeg"
assert result.additional_kwargs["read_file_media_type"] == "image/jpeg"
_, image = _decode_block(result)
assert max(image.size) <= MAX_IMAGE_EDGE
assert image.format == "JPEG"
# Aspect ratio preserved: 3000x1200 -> 2048x819.
assert image.size == (2048, 819)
def test_oversized_alpha_png_stays_png(self):
message = _image_message(_b64_image("PNG", (2500, 2500), mode="RGBA"))
result = process_image_tool_message(message)
block, image = _decode_block(result)
assert block["mime_type"] == "image/png"
assert image.format == "PNG"
assert image.mode == "RGBA"
assert max(image.size) <= MAX_IMAGE_EDGE
def test_unscaled_returns_exact_cap_when_equal(self):
# Exactly at the cap: no scaling — bytes kept, metadata attached.
b64 = _b64_image("PNG", (MAX_IMAGE_EDGE, 10))
result = process_image_tool_message(_image_message(b64))
assert result.content[-1]["base64"] == b64
assert "downsampled" not in result.content[0]["text"]
class TestMimeSniffing:
def test_extension_lie_corrected(self):
# JPEG bytes in a .png-named file: declared image/png, sniffed JPEG.
message = _image_message(
_b64_image("JPEG", (200, 200)), media_type="image/png"
)
result = process_image_tool_message(message)
block, image = _decode_block(result)
assert block["mime_type"] == "image/jpeg"
assert result.additional_kwargs["read_file_media_type"] == "image/jpeg"
assert image.format == "JPEG"
meta = result.content[0]
assert "re-encoded" in meta["text"]
def test_gif_first_frame(self):
# Small GIFs keep their bytes (plus metadata); an oversized GIF
# exercises the transcode path (first frame -> JPEG).
small_b64 = _b64_image("GIF", (300, 100))
small = _image_message(
small_b64,
media_type="image/gif",
path="/artifacts/anim.gif",
)
small_result = process_image_tool_message(small)
assert small_result.content[-1]["base64"] == small_b64
assert small_result.content[-1]["mime_type"] == "image/gif"
message = _image_message(
_b64_image("GIF", (3000, 100)),
media_type="image/gif",
path="/artifacts/anim.gif",
)
result = process_image_tool_message(message)
block, image = _decode_block(result)
assert block["mime_type"] == "image/jpeg"
assert image.format == "JPEG"
assert max(image.size) <= MAX_IMAGE_EDGE
class TestErrors:
def test_corrupt_image_is_explicit_error(self):
message = _image_message(base64.standard_b64encode(b"not an image").decode())
result = process_image_tool_message(message)
assert result.status == "error"
assert "无法读取图像数据" in result.content
assert "/artifacts/x.png" in result.content
assert result.tool_call_id == "call-1"
def test_invalid_base64_is_explicit_error(self):
message = _image_message("!!!not-base64!!!")
result = process_image_tool_message(message)
assert result.status == "error"
assert "无法读取图像数据" in result.content
class TestOpenAICompatDelivery:
"""The model must actually receive the image on OpenAI-compatible
providers: tool-role messages only carry text there, so the image has to
be hoisted into a following user message with the metadata text staying
on the tool result."""
def test_processed_message_hoists_image_keeps_metadata(self):
from EvoScientist.llm.patches import _sanitize_messages
message = process_image_tool_message(
_image_message(_b64_image("PNG", (100, 80)))
)
out = _sanitize_messages([message], hoist_tool_media=True)
assert len(out) == 2
tool_msg, hoisted = out
# Tool result keeps an informative text body — never empty.
assert tool_msg.type == "tool"
assert isinstance(tool_msg.content, str)
assert "100x80" in tool_msg.content
# The image rides a following user message.
assert hoisted.type == "human"
assert any(
isinstance(b, dict) and b.get("type") == "image" for b in hoisted.content
)
def test_openai_payload_is_protocol_legal(self):
from langchain_openai.chat_models.base import _convert_message_to_dict
from EvoScientist.llm.patches import _sanitize_messages
message = process_image_tool_message(
_image_message(_b64_image("PNG", (100, 80)))
)
out = _sanitize_messages([message], hoist_tool_media=True)
tool_dict = _convert_message_to_dict(out[0])
user_dict = _convert_message_to_dict(out[1])
# tool role: plain string content only.
assert tool_dict["role"] == "tool"
assert isinstance(tool_dict["content"], str)
# user role: image_url data URI.
assert user_dict["role"] == "user"
assert any(
isinstance(b, dict) and b.get("type") == "image_url"
for b in user_dict["content"]
)
def test_chat_openai_builder_applies_hoist_patch(self):
from EvoScientist.model_registry.factory import _build_chat_openai
model = _build_chat_openai(
{"model": "qwen3.7-plus", "api_key": "sk-test"}, None, None
)
# functools.wraps marks the wrapper; unpatched ChatOpenAI has none.
assert getattr(model._generate, "__wrapped__", None) is not None
assert getattr(model._astream, "__wrapped__", None) is not None
class TestInstall:
def test_install_is_idempotent(self):
from deepagents.middleware.filesystem import FilesystemMiddleware
first = FilesystemMiddleware._create_read_file_tool
install_read_file_image_patch()
install_read_file_image_patch()
assert FilesystemMiddleware._create_read_file_tool is first
@pytest.mark.asyncio
async def test_wrapped_tool_processes_images(self, tmp_path):
from deepagents.backends.filesystem import FilesystemBackend
from deepagents.middleware.filesystem import FilesystemMiddleware
install_read_file_image_patch()
big = tmp_path / "big.png"
Image.new("RGB", (3000, 500), color=(1, 2, 3)).save(big, "PNG")
middleware = FilesystemMiddleware(
backend=FilesystemBackend(root_dir=str(tmp_path), virtual_mode=True)
)
tool = next(t for t in middleware.tools if t.name == "read_file")
class _Runtime:
tool_call_id = "call-9"
result = await tool.coroutine(
file_path="/big.png", runtime=_Runtime(), offset=0, limit=2000
)
assert result.status == "success"
assert isinstance(result.content, list) and len(result.content) == 2
assert result.content[0]["type"] == "text"
assert "3000x500" in result.content[0]["text"]
_, image = _decode_block(result)
assert max(image.size) <= MAX_IMAGE_EDGE
def test_wrapped_tool_keeps_text_behavior(self, tmp_path):
from deepagents.backends.filesystem import FilesystemBackend
from deepagents.middleware.filesystem import FilesystemMiddleware
install_read_file_image_patch()
(tmp_path / "notes.txt").write_text("alpha\nbeta\n")
middleware = FilesystemMiddleware(
backend=FilesystemBackend(root_dir=str(tmp_path), virtual_mode=True)
)
tool = next(t for t in middleware.tools if t.name == "read_file")
class _Runtime:
tool_call_id = "call-10"
result = tool.func(
file_path="/notes.txt", runtime=_Runtime(), offset=0, limit=2000
)
assert result.status == "success"
assert "alpha" in result.content
assert "beta" in result.content
def test_wrapped_tool_preserves_runtime_injection(self, tmp_path):
"""Regression: *args/**kwargs wrappers hid the runtime parameter, so
StructuredTool/ToolNode never injected it and every call raised
"missing 1 required positional argument: 'runtime'"."""
import inspect
from deepagents.backends.filesystem import FilesystemBackend
from deepagents.middleware.filesystem import FilesystemMiddleware
from langchain_core.tools.base import _is_injected_arg_type
install_read_file_image_patch()
middleware = FilesystemMiddleware(
backend=FilesystemBackend(root_dir=str(tmp_path), virtual_mode=True)
)
tool = next(t for t in middleware.tools if t.name == "read_file")
for fn in (tool.func, tool.coroutine):
params = inspect.signature(fn).parameters
assert "runtime" in params
assert _is_injected_arg_type(params["runtime"].annotation)
# The exact mechanism langchain uses to decide injection.
assert "runtime" in tool._injected_args_keys
Generated
+87
View File
@@ -963,6 +963,7 @@ dependencies = [
{ name = "lazy-loader" },
{ name = "markdownify" },
{ name = "nest-asyncio" },
{ name = "pillow" },
{ name = "prompt-toolkit" },
{ name = "psutil" },
{ name = "pydantic" },
@@ -1073,6 +1074,7 @@ requires-dist = [
{ name = "lazy-loader", specifier = ">=0.5" },
{ name = "markdownify", specifier = ">=1.2" },
{ name = "nest-asyncio", specifier = ">=1.6" },
{ name = "pillow", specifier = ">=10" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5.0" },
{ name = "prompt-toolkit", specifier = ">=3.0" },
{ name = "psutil", specifier = ">=6.0" },
@@ -3066,6 +3068,91 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
{ url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
{ url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
{ url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
{ url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
{ url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
{ url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
{ url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
{ url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
{ url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
{ url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
{ url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.6"