diff --git a/EvoScientist/backends.py b/EvoScientist/backends.py index 8e680b2..6bcec36 100644 --- a/EvoScientist/backends.py +++ b/EvoScientist/backends.py @@ -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 ``/``) 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 ``/``) 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. diff --git a/EvoScientist/gateway/background_runs.py b/EvoScientist/gateway/background_runs.py index 5659dbc..6c7c734 100644 --- a/EvoScientist/gateway/background_runs.py +++ b/EvoScientist/gateway/background_runs.py @@ -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, diff --git a/EvoScientist/image_gen/adapters/openai.py b/EvoScientist/image_gen/adapters/openai.py index 607622d..23c5be5 100644 --- a/EvoScientist/image_gen/adapters/openai.py +++ b/EvoScientist/image_gen/adapters/openai.py @@ -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: diff --git a/EvoScientist/image_gen/config.py b/EvoScientist/image_gen/config.py index 279e2e4..0c810e9 100644 --- a/EvoScientist/image_gen/config.py +++ b/EvoScientist/image_gen/config.py @@ -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) diff --git a/EvoScientist/image_gen/service.py b/EvoScientist/image_gen/service.py index 7978f0b..0305fb3 100644 --- a/EvoScientist/image_gen/service.py +++ b/EvoScientist/image_gen/service.py @@ -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) diff --git a/EvoScientist/memory/launch.py b/EvoScientist/memory/launch.py index f94759e..3734cd8 100644 --- a/EvoScientist/memory/launch.py +++ b/EvoScientist/memory/launch.py @@ -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: diff --git a/EvoScientist/middleware/__init__.py b/EvoScientist/middleware/__init__.py index 07c4dbd..9c42c7d 100644 --- a/EvoScientist/middleware/__init__.py +++ b/EvoScientist/middleware/__init__.py @@ -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", diff --git a/EvoScientist/middleware/message_budget.py b/EvoScientist/middleware/message_budget.py index 66931e5..0b9b13a 100644 --- a/EvoScientist/middleware/message_budget.py +++ b/EvoScientist/middleware/message_budget.py @@ -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: diff --git a/EvoScientist/middleware/read_file_images.py b/EvoScientist/middleware/read_file_images.py new file mode 100644 index 0000000..3e9955b --- /dev/null +++ b/EvoScientist/middleware/read_file_images.py @@ -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 diff --git a/EvoScientist/model_registry/factory.py b/EvoScientist/model_registry/factory.py index cb2d0d9..f726c1f 100644 --- a/EvoScientist/model_registry/factory.py +++ b/EvoScientist/model_registry/factory.py @@ -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( diff --git a/EvoScientist/model_registry/http_api.py b/EvoScientist/model_registry/http_api.py index bba5072..61f376c 100644 --- a/EvoScientist/model_registry/http_api.py +++ b/EvoScientist/model_registry/http_api.py @@ -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 diff --git a/EvoScientist/prompts.py b/EvoScientist/prompts.py index 726d657..1c747ad 100644 --- a/EvoScientist/prompts.py +++ b/EvoScientist/prompts.py @@ -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, diff --git a/EvoScientist/tools/image.py b/EvoScientist/tools/image.py index 1ee889b..9381334 100644 --- a/EvoScientist/tools/image.py +++ b/EvoScientist/tools/image.py @@ -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, diff --git a/README.md b/README.md index 3ae8704..d9c7ad8 100644 --- a/README.md +++ b/README.md @@ -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

🔝Back to top

+## 🚢 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= +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 `/.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. + +

🔝Back to top

+ ## 📚 Acknowledgments This project builds upon the following outstanding open-source works: diff --git a/README.zh-CN.md b/README.zh-CN.md index a4cdb23..6ea860c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -193,6 +193,7 @@ EvoScientist 超越了传统的人在回路(Human-in-the-Loop)模式,采 - [🍪 示例与实践](#-示例与实践) - [🔌 MCP 集成](#-mcp-集成) - [📱 渠道接入](#-渠道接入) +- [🚢 发布与更新检查](#-发布与更新检查) - [📚 致谢](#-致谢) - [🎯 路线图](#-ᯓ-路线图) - [🌍 项目角色](#-项目角色) @@ -647,6 +648,44 @@ channel_enabled: "telegram,slack,feishu,qq"

🔝回到顶部

+## 🚢 发布与更新检查 + +版本发布到自托管 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 优先级下载发布产物到 `/.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` 为真时角标变琥珀色并带红点脉冲;下拉菜单提供发布说明链接、强制刷新,以及下载按钮——下载完成后展示产物路径和可一键复制的安装命令。 + +

🔝回到顶部

+ ## 📚 致谢 本项目基于以下优秀的开源项目构建: diff --git a/pyproject.toml b/pyproject.toml index 17042c3..183dc3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/scripts/dev_backend.sh b/scripts/dev_backend.sh new file mode 100755 index 0000000..739e077 --- /dev/null +++ b/scripts/dev_backend.sh @@ -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 /.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 diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..85b64ee --- /dev/null +++ b/scripts/release.sh @@ -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 }" +NOTES="${2:?usage: release.sh }" +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" diff --git a/tests/test_backends.py b/tests/test_backends.py index 8fbdf03..bd075b8 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -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 ``/``; 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/) === diff --git a/tests/test_image_gen_config.py b/tests/test_image_gen_config.py index 8344a70..4cae2c6 100644 --- a/tests/test_image_gen_config.py +++ b/tests/test_image_gen_config.py @@ -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" diff --git a/tests/test_image_gen_service.py b/tests/test_image_gen_service.py index 1433a95..3bc593e 100644 --- a/tests/test_image_gen_service.py +++ b/tests/test_image_gen_service.py @@ -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" diff --git a/tests/test_image_generation_http.py b/tests/test_image_generation_http.py index 42e5f01..9cda9cf 100644 --- a/tests/test_image_generation_http.py +++ b/tests/test_image_generation_http.py @@ -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 diff --git a/tests/test_image_tools.py b/tests/test_image_tools.py index 9527557..960b3d5 100644 --- a/tests/test_image_tools.py +++ b/tests/test_image_tools.py @@ -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(): diff --git a/tests/test_image_tools_blocking.py b/tests/test_image_tools_blocking.py new file mode 100644 index 0000000..51b1e8c --- /dev/null +++ b/tests/test_image_tools_blocking.py @@ -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" + ) diff --git a/tests/test_message_budget.py b/tests/test_message_budget.py index c895a8e..4529daf 100644 --- a/tests/test_message_budget.py +++ b/tests/test_message_budget.py @@ -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) diff --git a/tests/test_observation_memory.py b/tests/test_observation_memory.py index 4c8662e..2100bde 100644 --- a/tests/test_observation_memory.py +++ b/tests/test_observation_memory.py @@ -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, ): diff --git a/tests/test_read_file_images.py b/tests/test_read_file_images.py new file mode 100644 index 0000000..b4d7d5a --- /dev/null +++ b/tests/test_read_file_images.py @@ -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"").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 diff --git a/uv.lock b/uv.lock index ff843c1..aa5bfd8 100644 --- a/uv.lock +++ b/uv.lock @@ -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"