Skip to content

Commit 15472a5

Browse files
DavdGaoclaude
andcommitted
feat(tool): configure accepted image media types on Read and build its schema from a params model
- Replace `image_format`/Pillow conversion with `image_types`, a list of image media types (or globs) the downstream model accepts; defaults to png/jpeg/gif/webp and a model card's `input_types` can be passed as-is. Images of other types return an error instead of a DataBlock. - Define `_ReadParams(ParamsBase)` and expose `input_schema`/`description` as properties so the supported image types render into the description. - Drop Pillow from the core dependencies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent de9b76d commit 15472a5

3 files changed

Lines changed: 138 additions & 287 deletions

File tree

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ dependencies = [
4444
"tree_sitter",
4545
"tree_sitter_bash",
4646
"jsonschema",
47-
"Pillow",
4847
"pypdf",
4948
# The IANA timezone database, which is absent on Windows and slim images
5049
"tzdata",

src/agentscope/tool/_builtin/_read.py

Lines changed: 97 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
"""The read tool in agentscope."""
33
import base64
44
import fnmatch
5-
import io
65
import os
76
import re
8-
from typing import Any, List, Literal
7+
from typing import Any, List
98

10-
from .._base import ToolBase, ToolMiddlewareBase
9+
from pydantic import Field
10+
11+
from .._base import ParamsBase, ToolBase, ToolMiddlewareBase
1112
from ...permission import (
1213
PermissionContext,
1314
PermissionDecision,
@@ -41,6 +42,37 @@
4142
_PDF_MAX_PAGES_WITHOUT_RANGE = 10
4243
_PDF_MAX_PAGES_PER_READ = 20
4344

45+
# Image types accepted by the Anthropic, OpenAI, Gemini and DashScope APIs.
46+
_DEFAULT_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
47+
48+
49+
class _ReadParams(ParamsBase):
50+
"""The parameters of the Read tool."""
51+
52+
file_path: str = Field(
53+
description="The absolute path to the file to read.",
54+
)
55+
offset: int = Field(
56+
default=1,
57+
ge=1,
58+
description="Optional 1-based line number to start reading from. "
59+
"Only applies to plain text files (default: 1)",
60+
)
61+
limit: int = Field(
62+
default=2000,
63+
ge=1,
64+
le=2000,
65+
description="Optional maximum number of lines to read. Only applies "
66+
"to plain text files (default: 2000, max: 2000)",
67+
)
68+
pages: str | None = Field(
69+
default=None,
70+
description='Page range for PDF files (e.g. "1-5", "3", "10-20"), '
71+
f"max {_PDF_MAX_PAGES_PER_READ} pages per request; required for "
72+
f"PDFs over {_PDF_MAX_PAGES_WITHOUT_RANGE} pages. Only applies to "
73+
"PDF files.",
74+
)
75+
4476

4577
class Read(ToolBase):
4678
"""The read tool."""
@@ -49,103 +81,77 @@ class Read(ToolBase):
4981
"""The tool name presented to the agent."""
5082

5183
# pylint: disable=line-too-long
52-
description: str = """Reads a file from the local filesystem. You can access any file directly by using this tool.
84+
_DESCRIPTION_TEMPLATE: str = """Reads a file from the local filesystem. You can access any file directly by using this tool.
5385
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
5486
5587
Usage:
5688
- The file_path parameter must be an absolute path, not a relative path
5789
- By default, it reads up to 2000 lines starting from the beginning of the file
5890
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
5991
- Results are returned using cat -n format, with line numbers starting at 1
60-
- This tool allows you to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as you're a multimodal LLM.
61-
- This tool can read PDF files (.pdf). Text is extracted per page. For large PDFs (more than 10 pages), you MUST provide the pages parameter to read specific pages (max 20 pages per request).""" # noqa: E501
62-
"""The description presented to the agent."""
63-
64-
input_schema: dict[str, Any] = {
65-
"type": "object",
66-
"properties": {
67-
"file_path": {
68-
"type": "string",
69-
"description": "The absolute path to the file to read.",
70-
},
71-
"offset": {
72-
"type": "integer",
73-
"description": "Optional 1-based line number to start reading "
74-
"from. Only applies to plain text files (default: 1)",
75-
"default": 1,
76-
"minimum": 1,
77-
},
78-
"limit": {
79-
"type": "integer",
80-
"description": "Optional maximum number of lines to read. "
81-
"Only applies to plain text files (default: 2000, max: 2000)",
82-
"default": 2000,
83-
"maximum": 2000,
84-
"minimum": 1,
85-
},
86-
"pages": {
87-
"type": "string",
88-
"description": 'Page range for PDF files (e.g. "1-5", '
89-
'"3", "10-20"), max 20 pages per request; required '
90-
"for PDFs over 10 pages. Only applies to PDF files.",
91-
},
92-
},
93-
"required": ["file_path"],
94-
}
92+
- This tool allows you to read images ({image_types}). When reading an image file the contents are presented visually as you're a multimodal LLM.
93+
- This tool can read PDF files (.pdf). Text is extracted per page. For large PDFs (more than {max_pages_without_range} pages), you MUST provide the pages parameter to read specific pages (max {max_pages_per_read} pages per request).""" # noqa: E501
94+
95+
@property
96+
def description(self) -> str: # type: ignore[override]
97+
"""The description presented to the agent, rendered with the
98+
supported image types."""
99+
return self._DESCRIPTION_TEMPLATE.format(
100+
image_types=", ".join(self._image_types),
101+
max_pages_without_range=_PDF_MAX_PAGES_WITHOUT_RANGE,
102+
max_pages_per_read=_PDF_MAX_PAGES_PER_READ,
103+
)
104+
105+
@property
106+
def input_schema(self) -> dict[str, Any]: # type: ignore[override]
107+
"""The input schema of the tool."""
108+
return _ReadParams.model_json_schema()
95109

96110
is_mcp: bool = False
97111
is_read_only: bool = True
98112
is_concurrency_safe: bool = True
99113
is_external_tool: bool = False
100114
is_state_injected: bool = True
101115

102-
_IMAGE_FORMAT_MAP: dict[str, tuple[str, str]] = {
103-
"png": ("PNG", "image/png"),
104-
"jpeg": ("JPEG", "image/jpeg"),
105-
}
106-
107116
def __init__(
108117
self,
109118
max_line_characters: int = 2000,
110-
image_format: Literal["png", "jpeg"] | None = None,
119+
image_types: list[str] | None = None,
111120
middlewares: List[ToolMiddlewareBase] | None = None,
112121
backend: BackendBase | None = None,
113122
) -> None:
114123
"""Initialize the read tool.
115124
116125
Args:
117126
max_line_characters (`int`, defaults to 2000):
118-
The maximum number of characters to include
119-
for each line when reading files. Lines longer
120-
than this will be truncated with a "[truncated]"
121-
suffix.
122-
image_format (`Literal["png","jpeg"] | None`,
123-
optional):
124-
Target format for image conversion. Accepts
125-
``"png"`` or ``"jpeg"``. When ``None`` (default),
126-
images are returned in their original format.
127-
Requires Pillow when set.
128-
middlewares (`List[ToolMiddlewareBase] | None`,
129-
optional):
127+
The maximum number of characters to include for each line when
128+
reading files. Lines longer than this will be truncated with
129+
a "[truncated]" suffix. This prevents overwhelming the agent
130+
with excessively long lines while still providing useful
131+
content.
132+
image_types (`list[str] | None`, optional):
133+
The image media types the downstream model accepts, e.g.
134+
``["image/png", "image/jpeg"]`` or glob patterns like
135+
``"image/*"``. A model card's ``input_types`` can be passed
136+
directly since non-image entries are ignored. Reading an
137+
image of any other type returns an error. Defaults to
138+
``image/png``, ``image/jpeg``, ``image/gif`` and
139+
``image/webp``.
140+
middlewares (`List[ToolMiddlewareBase] | None`, optional):
130141
Tool middlewares wrapping the tool execution.
131142
backend (`BackendBase | None`, optional):
132-
The sandbox backend to use for file I/O. When
133-
``None``, a :class:`LocalBackend` is created.
143+
The sandbox backend to use for file I/O. When ``None``,
144+
a :class:`LocalBackend` is created.
134145
"""
135146
from ._backend import LocalBackend
136147

137-
if (
138-
image_format is not None
139-
and image_format not in self._IMAGE_FORMAT_MAP
140-
):
141-
raise ValueError(
142-
f"image_format must be 'png', 'jpeg', or "
143-
f"None, got '{image_format}'",
144-
)
145-
146148
super().__init__(middlewares=middlewares)
147149
self._max_line_characters = max_line_characters
148-
self._image_format = image_format
150+
self._image_types = [
151+
t
152+
for t in (image_types or _DEFAULT_IMAGE_TYPES)
153+
if t.startswith("image/")
154+
]
149155
self._backend = backend or LocalBackend()
150156

151157
async def check_permissions(
@@ -306,44 +312,20 @@ async def _read_image_file(
306312
) -> ToolChunk:
307313
"""Read an image file and return as DataBlock."""
308314
media_type = _IMAGE_EXTENSIONS[ext]
309-
310-
try:
311-
raw = await self._backend.read_file(file_path)
312-
313-
if self._image_format is not None:
314-
from PIL import Image
315-
316-
pil_fmt, media_type = self._IMAGE_FORMAT_MAP[
317-
self._image_format
318-
]
319-
img = Image.open(io.BytesIO(raw))
320-
if pil_fmt == "JPEG" and img.mode not in (
321-
"L",
322-
"RGB",
323-
"CMYK",
324-
):
325-
img = img.convert("RGB")
326-
buf = io.BytesIO()
327-
img.save(buf, format=pil_fmt)
328-
raw = buf.getvalue()
329-
330-
encoded = base64.b64encode(raw).decode(
331-
"ascii",
332-
)
333-
315+
if not any(fnmatch.fnmatch(media_type, t) for t in self._image_types):
334316
return ToolChunk(
335317
content=[
336-
DataBlock(
337-
source=Base64Source(
338-
data=encoded,
339-
media_type=media_type,
340-
),
341-
name=self._backend.basename(file_path),
318+
TextBlock(
319+
text=f"Error: Unsupported image type {media_type}, "
320+
f"only {', '.join(self._image_types)} are supported.",
342321
),
343322
],
344-
state=ToolResultState.RUNNING,
323+
state=ToolResultState.ERROR,
345324
is_last=True,
346325
)
326+
327+
try:
328+
raw = await self._backend.read_file(file_path)
347329
except Exception as e:
348330
return ToolChunk(
349331
content=[
@@ -353,6 +335,20 @@ async def _read_image_file(
353335
is_last=True,
354336
)
355337

338+
return ToolChunk(
339+
content=[
340+
DataBlock(
341+
source=Base64Source(
342+
data=base64.b64encode(raw).decode("ascii"),
343+
media_type=media_type,
344+
),
345+
name=self._backend.basename(file_path),
346+
),
347+
],
348+
state=ToolResultState.RUNNING,
349+
is_last=True,
350+
)
351+
356352
async def _read_pdf(
357353
self,
358354
file_path: str,

0 commit comments

Comments
 (0)