pi-durable-python 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pi_durable/__init__.py +320 -0
- pi_durable/context.py +328 -0
- pi_durable/delta.py +269 -0
- pi_durable/documents.py +366 -0
- pi_durable/entries.py +66 -0
- pi_durable/env/__init__.py +408 -0
- pi_durable/env/decode.py +48 -0
- pi_durable/env/line_scan.py +190 -0
- pi_durable/env/posix.py +1547 -0
- pi_durable/errors.py +89 -0
- pi_durable/harness/__init__.py +1 -0
- pi_durable/harness/agent.py +344 -0
- pi_durable/harness/compaction.py +673 -0
- pi_durable/harness/context.py +515 -0
- pi_durable/harness/define.py +111 -0
- pi_durable/harness/events.py +766 -0
- pi_durable/harness/generation.py +1079 -0
- pi_durable/harness/harness.py +570 -0
- pi_durable/harness/inbox.py +255 -0
- pi_durable/harness/live.py +374 -0
- pi_durable/harness/output.py +467 -0
- pi_durable/harness/prompt.py +288 -0
- pi_durable/harness/provider.py +78 -0
- pi_durable/harness/registry.py +136 -0
- pi_durable/harness/scheduler.py +1977 -0
- pi_durable/harness/submissions.py +357 -0
- pi_durable/harness/task_graph.py +334 -0
- pi_durable/harness/tool.py +1365 -0
- pi_durable/harness/types.py +772 -0
- pi_durable/harness/usage.py +88 -0
- pi_durable/harness/util.py +204 -0
- pi_durable/harness/validation.py +460 -0
- pi_durable/harness/view.py +324 -0
- pi_durable/ids.py +63 -0
- pi_durable/json.py +186 -0
- pi_durable/providers/__init__.py +362 -0
- pi_durable/providers/anthropic.py +603 -0
- pi_durable/providers/base.py +97 -0
- pi_durable/providers/json_parse.py +117 -0
- pi_durable/providers/openai.py +574 -0
- pi_durable/providers/request.py +35 -0
- pi_durable/providers/retry.py +127 -0
- pi_durable/providers/stream.py +183 -0
- pi_durable/providers/transcript.py +224 -0
- pi_durable/providers/usage.py +58 -0
- pi_durable/py.typed +0 -0
- pi_durable/search.py +115 -0
- pi_durable/session/__init__.py +5 -0
- pi_durable/session/drafts.py +762 -0
- pi_durable/session/forks.py +109 -0
- pi_durable/session/observation.py +374 -0
- pi_durable/session/session.py +1193 -0
- pi_durable/session/transaction.py +1088 -0
- pi_durable/storage/__init__.py +5 -0
- pi_durable/storage/jsonl/__init__.py +15 -0
- pi_durable/storage/jsonl/storage.py +958 -0
- pi_durable/storage/memory.py +889 -0
- pi_durable/storage/scan.py +57 -0
- pi_durable/storage/sqlite/__init__.py +26 -0
- pi_durable/storage/sqlite/database.py +62 -0
- pi_durable/storage/sqlite/migrations.py +191 -0
- pi_durable/storage/sqlite/posix.py +314 -0
- pi_durable/storage/sqlite/schema.py +97 -0
- pi_durable/storage/sqlite/storage.py +1130 -0
- pi_durable/tasks.py +89 -0
- pi_durable/testing/__init__.py +41 -0
- pi_durable/testing/assertions.py +44 -0
- pi_durable/testing/env_conformance.py +828 -0
- pi_durable/testing/runner.py +64 -0
- pi_durable/testing/storage_conformance.py +2008 -0
- pi_durable/testing/types.py +99 -0
- pi_durable/tool_search/__init__.py +159 -0
- pi_durable/tools/__init__.py +81 -0
- pi_durable/tools/_values.py +43 -0
- pi_durable/tools/bash.py +250 -0
- pi_durable/tools/edit.py +214 -0
- pi_durable/tools/edit_diff.py +452 -0
- pi_durable/tools/env.py +19 -0
- pi_durable/tools/file_mutation_queue.py +68 -0
- pi_durable/tools/image.py +248 -0
- pi_durable/tools/image_processor.py +77 -0
- pi_durable/tools/path_utils.py +111 -0
- pi_durable/tools/read.py +506 -0
- pi_durable/tools/write.py +71 -0
- pi_durable/truncate.py +185 -0
- pi_durable/types.py +1297 -0
- pi_durable_python-1.3.0.dist-info/METADATA +276 -0
- pi_durable_python-1.3.0.dist-info/RECORD +90 -0
- pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
- pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
pi_durable/tools/read.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""The ``read`` coding tool: bounded text reads and image blocks.
|
|
2
|
+
|
|
3
|
+
Port of the v1.3.0 ``src/tools/read.ts``. Text is read through ``openBinaryReader`` and
|
|
4
|
+
``scanLines``, so a read costs one scan of the file plus the shown head; images are detected by
|
|
5
|
+
content and returned as one image block.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
import math
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from typing import Any, TypedDict, cast
|
|
14
|
+
|
|
15
|
+
from ..context import Context
|
|
16
|
+
from ..env import (
|
|
17
|
+
BinaryReader,
|
|
18
|
+
FileInfo,
|
|
19
|
+
get_or_throw,
|
|
20
|
+
range_decoder,
|
|
21
|
+
starts_with_bom,
|
|
22
|
+
)
|
|
23
|
+
from ..harness.define import define_tool
|
|
24
|
+
from ..harness.types import ToolDiagnostic, ToolExecutionApi, ToolExecutionResult, ToolRegistration
|
|
25
|
+
from ..json import JsonValue
|
|
26
|
+
from ..truncate import (
|
|
27
|
+
DEFAULT_MAX_BYTES,
|
|
28
|
+
DEFAULT_MAX_LINES,
|
|
29
|
+
TruncationResult,
|
|
30
|
+
format_size,
|
|
31
|
+
truncate_head_of,
|
|
32
|
+
utf8_byte_length,
|
|
33
|
+
)
|
|
34
|
+
from ._values import field, field_either, js_number_string
|
|
35
|
+
from .env import require_env
|
|
36
|
+
from .image import (
|
|
37
|
+
ReaderByteSource,
|
|
38
|
+
detect_supported_image_mime_type_of,
|
|
39
|
+
image_dimensions,
|
|
40
|
+
)
|
|
41
|
+
from .image_processor import (
|
|
42
|
+
DEFAULT_IMAGE_LIMITS,
|
|
43
|
+
INLINE_IMAGE_TYPES,
|
|
44
|
+
ImageLimits,
|
|
45
|
+
ImageProcessor,
|
|
46
|
+
base64_length,
|
|
47
|
+
to_base64,
|
|
48
|
+
)
|
|
49
|
+
from .path_utils import resolve_read_tool_path
|
|
50
|
+
|
|
51
|
+
READ_CHUNK = 64 * 1024
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ReadToolInput(TypedDict, total=False):
|
|
55
|
+
"""Arguments of one ``read`` call."""
|
|
56
|
+
|
|
57
|
+
path: str
|
|
58
|
+
offset: int | float
|
|
59
|
+
limit: int | float
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ReadToolDetails(TypedDict, total=False):
|
|
63
|
+
"""How the shown text was cut; the text itself is the result content."""
|
|
64
|
+
|
|
65
|
+
truncation: dict[str, Any]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ReadToolOptions(TypedDict, total=False):
|
|
69
|
+
"""Options of :func:`create_read_tool`."""
|
|
70
|
+
|
|
71
|
+
images: ImageProcessor
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
READ_SCHEMA: dict[str, Any] = {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"properties": {
|
|
77
|
+
"path": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"description": "Path to the file to read (relative or absolute)",
|
|
80
|
+
},
|
|
81
|
+
"offset": {
|
|
82
|
+
"type": "number",
|
|
83
|
+
"description": "Line number to start reading from (1-indexed)",
|
|
84
|
+
},
|
|
85
|
+
"limit": {
|
|
86
|
+
"type": "number",
|
|
87
|
+
"description": "Maximum number of lines to read",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
"required": ["path"],
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _is_safe_integer(value: Any) -> bool:
|
|
95
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
96
|
+
return False
|
|
97
|
+
if isinstance(value, float):
|
|
98
|
+
if not math.isfinite(value) or not value.is_integer():
|
|
99
|
+
return False
|
|
100
|
+
return abs(value) <= 9_007_199_254_740_991
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _slice_index(value: float) -> float:
|
|
104
|
+
if isinstance(value, float) and math.isnan(value):
|
|
105
|
+
return 0
|
|
106
|
+
if isinstance(value, float) and math.isinf(value):
|
|
107
|
+
return value
|
|
108
|
+
return math.trunc(value)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _truthy_number(value: Any) -> bool:
|
|
112
|
+
return (
|
|
113
|
+
isinstance(value, (int, float))
|
|
114
|
+
and not isinstance(value, bool)
|
|
115
|
+
and not (isinstance(value, float) and math.isnan(value))
|
|
116
|
+
and value != 0
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_integral(value: float) -> bool:
|
|
121
|
+
return not isinstance(value, bool) and float(value).is_integer()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _character_end(data: bytes, index: int) -> int:
|
|
125
|
+
"""The last character boundary at or before ``index``."""
|
|
126
|
+
|
|
127
|
+
end = min(index, len(data))
|
|
128
|
+
while 0 < end < len(data) and (data[end] & 0xC0) == 0x80:
|
|
129
|
+
end -= 1
|
|
130
|
+
return end
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _truncation_details(truncation: TruncationResult) -> dict[str, Any]:
|
|
134
|
+
return {
|
|
135
|
+
"truncated": truncation.truncated,
|
|
136
|
+
"truncatedBy": truncation.truncated_by,
|
|
137
|
+
"totalLines": truncation.total_lines,
|
|
138
|
+
"totalBytes": truncation.total_bytes,
|
|
139
|
+
"outputLines": truncation.output_lines,
|
|
140
|
+
"outputBytes": truncation.output_bytes,
|
|
141
|
+
"lastLinePartial": truncation.last_line_partial,
|
|
142
|
+
"firstLineExceedsLimit": truncation.first_line_exceeds_limit,
|
|
143
|
+
"maxLines": truncation.max_lines,
|
|
144
|
+
"maxBytes": truncation.max_bytes,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def _read_head(
|
|
149
|
+
reader: BinaryReader,
|
|
150
|
+
start: int,
|
|
151
|
+
end: int,
|
|
152
|
+
skip_bom: bool,
|
|
153
|
+
context: Context,
|
|
154
|
+
) -> str:
|
|
155
|
+
"""The decoded start of ``[start, end)``, enough for :func:`truncate_head_of`."""
|
|
156
|
+
|
|
157
|
+
decoder = range_decoder()
|
|
158
|
+
text = ""
|
|
159
|
+
newlines = 0
|
|
160
|
+
position = 3 if skip_bom and start == 0 else start
|
|
161
|
+
while position < end:
|
|
162
|
+
data = get_or_throw(await reader.read(position, min(READ_CHUNK, end - position), context))
|
|
163
|
+
if len(data) == 0:
|
|
164
|
+
break
|
|
165
|
+
position += len(data)
|
|
166
|
+
decoded = decoder.decode(data, False)
|
|
167
|
+
text += decoded
|
|
168
|
+
newlines += decoded.count("\n")
|
|
169
|
+
if newlines >= DEFAULT_MAX_LINES or utf8_byte_length(text) > DEFAULT_MAX_BYTES + 1:
|
|
170
|
+
return text
|
|
171
|
+
return text + decoder.decode(b"", True)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def _read_text(
|
|
175
|
+
reader: BinaryReader,
|
|
176
|
+
path: str,
|
|
177
|
+
offset: int | float | None,
|
|
178
|
+
limit: int | float | None,
|
|
179
|
+
context: Context,
|
|
180
|
+
) -> ToolExecutionResult:
|
|
181
|
+
start_line: float = max(0, offset - 1) if _truthy_number(offset) else 0 # type: ignore[operator]
|
|
182
|
+
start_line_display = start_line + 1
|
|
183
|
+
# Lines are selected like ``allLines.slice(startLine, endLine)``, which truncates fractional indices.
|
|
184
|
+
slice_start = _slice_index(start_line)
|
|
185
|
+
# A start beyond any file is scanned from 0 only to count lines; the offset check below then fails.
|
|
186
|
+
scan_start = int(slice_start) if _is_safe_integer(slice_start) else 0
|
|
187
|
+
requested_end: float | None = (
|
|
188
|
+
None if limit is None else max(scan_start + 1, _slice_index(start_line + limit))
|
|
189
|
+
)
|
|
190
|
+
scan_end = int(requested_end) if requested_end is not None and _is_safe_integer(requested_end) else None
|
|
191
|
+
|
|
192
|
+
async def scan_of(end_line: int | None) -> dict[str, Any]:
|
|
193
|
+
options: dict[str, Any] = {"start_line": scan_start}
|
|
194
|
+
if end_line is not None:
|
|
195
|
+
options["end_line"] = end_line
|
|
196
|
+
return get_or_throw(await reader.scan_lines(options, context))
|
|
197
|
+
|
|
198
|
+
scan = await scan_of(scan_end)
|
|
199
|
+
total_file_lines = scan["newlines"] + 1
|
|
200
|
+
if start_line >= total_file_lines:
|
|
201
|
+
raise RuntimeError(
|
|
202
|
+
f"Offset {js_number_string(offset)} is beyond end of file ({total_file_lines} lines total)"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
user_limited_lines: float | None = None
|
|
206
|
+
selected_line_count: float = total_file_lines - slice_start
|
|
207
|
+
if limit is not None:
|
|
208
|
+
end_line = min(start_line + limit, total_file_lines)
|
|
209
|
+
user_limited_lines = end_line - start_line
|
|
210
|
+
# ``slice`` counts a negative end from the end of the lines; scan again for it.
|
|
211
|
+
relative_end = _slice_index(end_line)
|
|
212
|
+
if relative_end < 0:
|
|
213
|
+
slice_end = max(total_file_lines + relative_end, 0)
|
|
214
|
+
else:
|
|
215
|
+
slice_end = relative_end
|
|
216
|
+
selected_line_count = max(0, slice_end - slice_start)
|
|
217
|
+
if selected_line_count > 0 and relative_end < 0:
|
|
218
|
+
scan = await scan_of(int(slice_end))
|
|
219
|
+
|
|
220
|
+
empty = selected_line_count == 0
|
|
221
|
+
# Counted like ``truncate_head``: a trailing newline adds no line, and empty text has none.
|
|
222
|
+
ends_with_newline = (
|
|
223
|
+
not empty and scan["lastLineStart"] == scan["end"] and scan["lastLineStart"] > scan["start"]
|
|
224
|
+
)
|
|
225
|
+
total_lines = (
|
|
226
|
+
0
|
|
227
|
+
if empty or scan["selectedBytes"] == 0
|
|
228
|
+
else int(selected_line_count) - (1 if ends_with_newline else 0)
|
|
229
|
+
)
|
|
230
|
+
total_bytes = 0 if empty else scan["selectedBytes"]
|
|
231
|
+
first_bytes = get_or_throw(await reader.read(0, 3, context))
|
|
232
|
+
head = (
|
|
233
|
+
""
|
|
234
|
+
if empty
|
|
235
|
+
else await _read_head(
|
|
236
|
+
reader,
|
|
237
|
+
scan["start"],
|
|
238
|
+
scan["end"],
|
|
239
|
+
starts_with_bom(first_bytes),
|
|
240
|
+
context,
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
truncation = truncate_head_of(head, (total_lines, total_bytes))
|
|
245
|
+
diagnostics: list[ToolDiagnostic] = []
|
|
246
|
+
output_text = truncation.content
|
|
247
|
+
details: ReadToolDetails | None = None
|
|
248
|
+
if truncation.first_line_exceeds_limit:
|
|
249
|
+
# Show the start of the line, cut at the byte limit on a character boundary.
|
|
250
|
+
integral = _is_integral(start_line)
|
|
251
|
+
line_bytes = head.split("\n")[0].encode("utf-8") if integral else b""
|
|
252
|
+
line_size = scan["firstLineBytes"] if integral else 0
|
|
253
|
+
end = _character_end(line_bytes, DEFAULT_MAX_BYTES)
|
|
254
|
+
output_text = line_bytes[:end].decode("utf-8", "replace")
|
|
255
|
+
diagnostics.append(
|
|
256
|
+
ToolDiagnostic(
|
|
257
|
+
severity="warn",
|
|
258
|
+
code="truncated",
|
|
259
|
+
message=(
|
|
260
|
+
f"Line {js_number_string(start_line_display)} is {format_size(line_size)}, "
|
|
261
|
+
f"exceeds the {format_size(DEFAULT_MAX_BYTES)} limit; showing its first "
|
|
262
|
+
f"{format_size(end)}. Use bash: sed -n "
|
|
263
|
+
f"'{js_number_string(start_line_display)}p' {path} | tail -c +{end + 1}"
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
details = {
|
|
268
|
+
"truncation": {
|
|
269
|
+
**_truncation_details(truncation),
|
|
270
|
+
"outputBytes": end,
|
|
271
|
+
"outputLines": 1,
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
elif truncation.truncated:
|
|
275
|
+
end_line_display = start_line_display + truncation.output_lines - 1
|
|
276
|
+
next_offset = end_line_display + 1
|
|
277
|
+
limit_text = (
|
|
278
|
+
"" if truncation.truncated_by == "lines" else f" ({format_size(DEFAULT_MAX_BYTES)} limit)"
|
|
279
|
+
)
|
|
280
|
+
diagnostics.append(
|
|
281
|
+
ToolDiagnostic(
|
|
282
|
+
severity="info",
|
|
283
|
+
code="truncated",
|
|
284
|
+
message=(
|
|
285
|
+
f"Showing lines {js_number_string(start_line_display)}-"
|
|
286
|
+
f"{js_number_string(end_line_display)} of {total_file_lines}{limit_text}. "
|
|
287
|
+
f"Use offset={js_number_string(next_offset)} to continue."
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
details = {"truncation": _truncation_details(truncation)}
|
|
292
|
+
elif user_limited_lines is not None and start_line + user_limited_lines < total_file_lines:
|
|
293
|
+
remaining = total_file_lines - (start_line + user_limited_lines)
|
|
294
|
+
next_offset = start_line + user_limited_lines + 1
|
|
295
|
+
diagnostics.append(
|
|
296
|
+
ToolDiagnostic(
|
|
297
|
+
severity="info",
|
|
298
|
+
message=(
|
|
299
|
+
f"{js_number_string(remaining)} more lines in file. Use offset="
|
|
300
|
+
f"{js_number_string(next_offset)} to continue."
|
|
301
|
+
),
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
output: list[Any] = [] if output_text == "" else [{"type": "text", "text": output_text}]
|
|
306
|
+
result: ToolExecutionResult = {"output": output, "diagnostics": diagnostics}
|
|
307
|
+
if details is not None:
|
|
308
|
+
result["details"] = cast(JsonValue, details)
|
|
309
|
+
return result
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class _ImageModel(TypedDict):
|
|
313
|
+
vision: bool
|
|
314
|
+
limits: ImageLimits
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
async def _image_model(api: ToolExecutionApi, context: Context) -> _ImageModel:
|
|
318
|
+
"""The conversation model's image input, overridden by its ``inputLimits.images.resize``."""
|
|
319
|
+
|
|
320
|
+
agent = await api.agent(context)
|
|
321
|
+
ref = field(agent, "model")
|
|
322
|
+
model: Any = None
|
|
323
|
+
if ref is not None:
|
|
324
|
+
provider = field(ref, "provider")
|
|
325
|
+
model_id = field_either(ref, "model_id", "modelId")
|
|
326
|
+
models = getattr(api, "models", None)
|
|
327
|
+
get_model = getattr(models, "get_model", None)
|
|
328
|
+
if callable(get_model) and provider is not None and model_id is not None:
|
|
329
|
+
model = get_model(provider, model_id)
|
|
330
|
+
input_types = field(model, "input")
|
|
331
|
+
limits_source = field_either(model, "input_limits", "inputLimits")
|
|
332
|
+
resize = field(field(limits_source, "images"), "resize")
|
|
333
|
+
limits: ImageLimits = {
|
|
334
|
+
"max_width": field_either(resize, "max_width", "maxWidth", DEFAULT_IMAGE_LIMITS["max_width"]),
|
|
335
|
+
"max_height": field_either(resize, "max_height", "maxHeight", DEFAULT_IMAGE_LIMITS["max_height"]),
|
|
336
|
+
"max_bytes": field_either(resize, "max_bytes", "maxBytes", DEFAULT_IMAGE_LIMITS["max_bytes"]),
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
"vision": True if input_types is None else "image" in input_types,
|
|
340
|
+
"limits": limits,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _image_error(message: str) -> ToolExecutionResult:
|
|
345
|
+
return {
|
|
346
|
+
"output": [],
|
|
347
|
+
"isError": True,
|
|
348
|
+
"diagnostics": [ToolDiagnostic(severity="error", code="unsupported_image", message=message)],
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
async def _read_image(
|
|
353
|
+
reader: BinaryReader,
|
|
354
|
+
info: FileInfo,
|
|
355
|
+
path: str,
|
|
356
|
+
mime_type: str,
|
|
357
|
+
processor: ImageProcessor | None,
|
|
358
|
+
image_model: _ImageModel,
|
|
359
|
+
context: Context,
|
|
360
|
+
) -> ToolExecutionResult:
|
|
361
|
+
"""One image block, prepared by ``processor`` or passed through within the limits."""
|
|
362
|
+
|
|
363
|
+
notes: list[str] = []
|
|
364
|
+
data: str
|
|
365
|
+
output_mime: str
|
|
366
|
+
if processor is not None:
|
|
367
|
+
bytes_ = get_or_throw(await reader.read(0, info["size"], context))
|
|
368
|
+
raw_prepared: Any = processor.prepare(bytes_, mime_type, image_model["limits"])
|
|
369
|
+
prepared = await raw_prepared if inspect.isawaitable(raw_prepared) else raw_prepared
|
|
370
|
+
if prepared is None:
|
|
371
|
+
return _image_error(f"{path} is an image ({mime_type}) that cannot be prepared for the model")
|
|
372
|
+
data = field(prepared, "data", "")
|
|
373
|
+
output_mime = field_either(prepared, "mime_type", "mimeType", mime_type)
|
|
374
|
+
converted_from = field_either(prepared, "converted_from", "convertedFrom")
|
|
375
|
+
if converted_from is not None:
|
|
376
|
+
notes.append(f"Converted from {converted_from} to {output_mime}.")
|
|
377
|
+
resized = field(prepared, "resized")
|
|
378
|
+
if resized is not None:
|
|
379
|
+
source = field(resized, "from")
|
|
380
|
+
target = field(resized, "to")
|
|
381
|
+
from_width = field(source, "width")
|
|
382
|
+
from_height = field(source, "height")
|
|
383
|
+
to_width = field(target, "width")
|
|
384
|
+
to_height = field(target, "height")
|
|
385
|
+
if None not in (from_width, from_height, to_width, to_height) and to_width:
|
|
386
|
+
scale = f"{from_width / to_width:.2f}"
|
|
387
|
+
notes.append(
|
|
388
|
+
f"Resized from {from_width}x{from_height} to {to_width}x{to_height}. "
|
|
389
|
+
f"Multiply coordinates by {scale} to map them to the original."
|
|
390
|
+
)
|
|
391
|
+
else:
|
|
392
|
+
if mime_type not in INLINE_IMAGE_TYPES:
|
|
393
|
+
return _image_error(
|
|
394
|
+
f"{path} is an image ({mime_type}) that needs converting, and no image processor "
|
|
395
|
+
"is configured"
|
|
396
|
+
)
|
|
397
|
+
unshrinkable = "and no image processor is configured to shrink it"
|
|
398
|
+
limits = image_model["limits"]
|
|
399
|
+
if base64_length(info["size"]) > limits["max_bytes"]:
|
|
400
|
+
return _image_error(
|
|
401
|
+
f"{path} is an image ({mime_type}) of {format_size(info['size'])}, too large to "
|
|
402
|
+
f"send, {unshrinkable}"
|
|
403
|
+
)
|
|
404
|
+
bytes_ = get_or_throw(await reader.read(0, info["size"], context))
|
|
405
|
+
size = image_dimensions(bytes_, mime_type)
|
|
406
|
+
if size is None:
|
|
407
|
+
return _image_error(f"{path} is an image ({mime_type}) whose size cannot be read")
|
|
408
|
+
if size["width"] > limits["max_width"] or size["height"] > limits["max_height"]:
|
|
409
|
+
return _image_error(
|
|
410
|
+
f"{path} is an image ({mime_type}) of {size['width']}x{size['height']}, larger "
|
|
411
|
+
f"than {limits['max_width']}x{limits['max_height']}, {unshrinkable}"
|
|
412
|
+
)
|
|
413
|
+
data = to_base64(bytes_)
|
|
414
|
+
output_mime = mime_type
|
|
415
|
+
|
|
416
|
+
if not image_model["vision"]:
|
|
417
|
+
notes.append("The current model does not support images; it sees a placeholder instead.")
|
|
418
|
+
message = " ".join([f"Read image file [{output_mime}].", *notes])
|
|
419
|
+
return {
|
|
420
|
+
"output": [{"type": "image", "data": data, "mimeType": output_mime}],
|
|
421
|
+
"diagnostics": [ToolDiagnostic(severity="info", code="image", message=message)],
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def create_read_tool(
|
|
426
|
+
options: ReadToolOptions | Mapping[str, Any] | None = None,
|
|
427
|
+
*,
|
|
428
|
+
images: ImageProcessor | None = None,
|
|
429
|
+
) -> ToolRegistration:
|
|
430
|
+
"""Read text files and images.
|
|
431
|
+
|
|
432
|
+
Truncation, continuation, and image remarks are diagnostics; the content is only file text, or
|
|
433
|
+
one image, which programs get as its ``ImageContent``.
|
|
434
|
+
"""
|
|
435
|
+
|
|
436
|
+
values: dict[str, Any] = dict(options or {})
|
|
437
|
+
if images is not None:
|
|
438
|
+
values["images"] = images
|
|
439
|
+
processor = values.get("images")
|
|
440
|
+
formats = "jpg, png, gif, webp" if processor is None else "jpg, png, gif, webp, bmp"
|
|
441
|
+
|
|
442
|
+
async def execute(
|
|
443
|
+
args: Mapping[str, Any], api: ToolExecutionApi, context: Context
|
|
444
|
+
) -> ToolExecutionResult:
|
|
445
|
+
path = str(args["path"])
|
|
446
|
+
offset = args.get("offset")
|
|
447
|
+
limit = args.get("limit")
|
|
448
|
+
env = require_env(api)
|
|
449
|
+
absolute_path = await resolve_read_tool_path(env, path, context)
|
|
450
|
+
reader = get_or_throw(await env.open_binary_reader(absolute_path, None, context))
|
|
451
|
+
try:
|
|
452
|
+
# A concurrent writer can change the file between the scan and the reads. Appending (a
|
|
453
|
+
# growing log) leaves the scanned text as it was; an image must be whole. Otherwise the
|
|
454
|
+
# file is read again once.
|
|
455
|
+
attempt = 0
|
|
456
|
+
while True:
|
|
457
|
+
before = get_or_throw(await reader.info(context))
|
|
458
|
+
mime_type = await detect_supported_image_mime_type_of(
|
|
459
|
+
ReaderByteSource(reader, before["size"], context)
|
|
460
|
+
)
|
|
461
|
+
if mime_type is None:
|
|
462
|
+
result = await _read_text(reader, path, offset, limit, context)
|
|
463
|
+
else:
|
|
464
|
+
result = await _read_image(
|
|
465
|
+
reader,
|
|
466
|
+
before,
|
|
467
|
+
path,
|
|
468
|
+
mime_type,
|
|
469
|
+
processor,
|
|
470
|
+
await _image_model(api, context),
|
|
471
|
+
context,
|
|
472
|
+
)
|
|
473
|
+
after = get_or_throw(await reader.info(context))
|
|
474
|
+
unchanged = after["size"] == before["size"] and after["mtimeMs"] == before["mtimeMs"]
|
|
475
|
+
if unchanged or (mime_type is None and after["size"] > before["size"]):
|
|
476
|
+
return result
|
|
477
|
+
if attempt == 1:
|
|
478
|
+
raise RuntimeError(f"{path} changed while it was read")
|
|
479
|
+
attempt += 1
|
|
480
|
+
finally:
|
|
481
|
+
await reader.close(context)
|
|
482
|
+
|
|
483
|
+
return define_tool(
|
|
484
|
+
{
|
|
485
|
+
"name": "read",
|
|
486
|
+
"description": (
|
|
487
|
+
"Read the contents of a file. Supports text files and images "
|
|
488
|
+
f"({formats}). Images are sent as attachments. For text files, output is "
|
|
489
|
+
f"truncated to {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB "
|
|
490
|
+
"(whichever is hit first). Use offset/limit for large files. When you need the "
|
|
491
|
+
"full file, continue with offset until complete."
|
|
492
|
+
),
|
|
493
|
+
"parameters": READ_SCHEMA,
|
|
494
|
+
"execute": execute,
|
|
495
|
+
}
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
__all__ = [
|
|
500
|
+
"READ_CHUNK",
|
|
501
|
+
"READ_SCHEMA",
|
|
502
|
+
"ReadToolDetails",
|
|
503
|
+
"ReadToolInput",
|
|
504
|
+
"ReadToolOptions",
|
|
505
|
+
"create_read_tool",
|
|
506
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""The ``write`` coding tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any, TypedDict
|
|
7
|
+
|
|
8
|
+
from ..context import Context
|
|
9
|
+
from ..env import get_or_throw
|
|
10
|
+
from ..harness.define import define_tool
|
|
11
|
+
from ..harness.types import ToolExecutionApi, ToolExecutionResult, ToolRegistration
|
|
12
|
+
from .env import require_env
|
|
13
|
+
from .file_mutation_queue import with_file_mutation_queue
|
|
14
|
+
from .path_utils import resolve_tool_path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WriteToolInput(TypedDict):
|
|
18
|
+
"""Arguments of one ``write`` call."""
|
|
19
|
+
|
|
20
|
+
path: str
|
|
21
|
+
content: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
WRITE_SCHEMA: dict[str, Any] = {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {
|
|
27
|
+
"path": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": "Path to the file to write (relative or absolute)",
|
|
30
|
+
},
|
|
31
|
+
"content": {"type": "string", "description": "Content to write to the file"},
|
|
32
|
+
},
|
|
33
|
+
"required": ["path", "content"],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_write_tool() -> ToolRegistration:
|
|
38
|
+
"""Write content to a file, creating it and its parent directories when needed."""
|
|
39
|
+
|
|
40
|
+
async def execute(
|
|
41
|
+
args: Mapping[str, Any], api: ToolExecutionApi, context: Context
|
|
42
|
+
) -> ToolExecutionResult:
|
|
43
|
+
path = str(args["path"])
|
|
44
|
+
content = str(args["content"])
|
|
45
|
+
env = require_env(api)
|
|
46
|
+
absolute_path = await resolve_tool_path(env, path, context)
|
|
47
|
+
|
|
48
|
+
async def mutate() -> ToolExecutionResult:
|
|
49
|
+
if context.abort_signal is not None and context.abort_signal.aborted:
|
|
50
|
+
raise RuntimeError("Operation aborted")
|
|
51
|
+
get_or_throw(await env.write_file(absolute_path, content, context))
|
|
52
|
+
if context.abort_signal is not None and context.abort_signal.aborted:
|
|
53
|
+
raise RuntimeError("Operation aborted")
|
|
54
|
+
return {"output": [{"type": "text", "text": f"Successfully wrote to {path}"}]}
|
|
55
|
+
|
|
56
|
+
return await with_file_mutation_queue(env, absolute_path, mutate, context)
|
|
57
|
+
|
|
58
|
+
return define_tool(
|
|
59
|
+
{
|
|
60
|
+
"name": "write",
|
|
61
|
+
"description": (
|
|
62
|
+
"Write content to a file. Creates the file if it doesn't exist, overwrites if it "
|
|
63
|
+
"does. Automatically creates parent directories."
|
|
64
|
+
),
|
|
65
|
+
"parameters": WRITE_SCHEMA,
|
|
66
|
+
"execute": execute,
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
__all__ = ["WRITE_SCHEMA", "WriteToolInput", "create_write_tool"]
|