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.
Files changed (90) hide show
  1. pi_durable/__init__.py +320 -0
  2. pi_durable/context.py +328 -0
  3. pi_durable/delta.py +269 -0
  4. pi_durable/documents.py +366 -0
  5. pi_durable/entries.py +66 -0
  6. pi_durable/env/__init__.py +408 -0
  7. pi_durable/env/decode.py +48 -0
  8. pi_durable/env/line_scan.py +190 -0
  9. pi_durable/env/posix.py +1547 -0
  10. pi_durable/errors.py +89 -0
  11. pi_durable/harness/__init__.py +1 -0
  12. pi_durable/harness/agent.py +344 -0
  13. pi_durable/harness/compaction.py +673 -0
  14. pi_durable/harness/context.py +515 -0
  15. pi_durable/harness/define.py +111 -0
  16. pi_durable/harness/events.py +766 -0
  17. pi_durable/harness/generation.py +1079 -0
  18. pi_durable/harness/harness.py +570 -0
  19. pi_durable/harness/inbox.py +255 -0
  20. pi_durable/harness/live.py +374 -0
  21. pi_durable/harness/output.py +467 -0
  22. pi_durable/harness/prompt.py +288 -0
  23. pi_durable/harness/provider.py +78 -0
  24. pi_durable/harness/registry.py +136 -0
  25. pi_durable/harness/scheduler.py +1977 -0
  26. pi_durable/harness/submissions.py +357 -0
  27. pi_durable/harness/task_graph.py +334 -0
  28. pi_durable/harness/tool.py +1365 -0
  29. pi_durable/harness/types.py +772 -0
  30. pi_durable/harness/usage.py +88 -0
  31. pi_durable/harness/util.py +204 -0
  32. pi_durable/harness/validation.py +460 -0
  33. pi_durable/harness/view.py +324 -0
  34. pi_durable/ids.py +63 -0
  35. pi_durable/json.py +186 -0
  36. pi_durable/providers/__init__.py +362 -0
  37. pi_durable/providers/anthropic.py +603 -0
  38. pi_durable/providers/base.py +97 -0
  39. pi_durable/providers/json_parse.py +117 -0
  40. pi_durable/providers/openai.py +574 -0
  41. pi_durable/providers/request.py +35 -0
  42. pi_durable/providers/retry.py +127 -0
  43. pi_durable/providers/stream.py +183 -0
  44. pi_durable/providers/transcript.py +224 -0
  45. pi_durable/providers/usage.py +58 -0
  46. pi_durable/py.typed +0 -0
  47. pi_durable/search.py +115 -0
  48. pi_durable/session/__init__.py +5 -0
  49. pi_durable/session/drafts.py +762 -0
  50. pi_durable/session/forks.py +109 -0
  51. pi_durable/session/observation.py +374 -0
  52. pi_durable/session/session.py +1193 -0
  53. pi_durable/session/transaction.py +1088 -0
  54. pi_durable/storage/__init__.py +5 -0
  55. pi_durable/storage/jsonl/__init__.py +15 -0
  56. pi_durable/storage/jsonl/storage.py +958 -0
  57. pi_durable/storage/memory.py +889 -0
  58. pi_durable/storage/scan.py +57 -0
  59. pi_durable/storage/sqlite/__init__.py +26 -0
  60. pi_durable/storage/sqlite/database.py +62 -0
  61. pi_durable/storage/sqlite/migrations.py +191 -0
  62. pi_durable/storage/sqlite/posix.py +314 -0
  63. pi_durable/storage/sqlite/schema.py +97 -0
  64. pi_durable/storage/sqlite/storage.py +1130 -0
  65. pi_durable/tasks.py +89 -0
  66. pi_durable/testing/__init__.py +41 -0
  67. pi_durable/testing/assertions.py +44 -0
  68. pi_durable/testing/env_conformance.py +828 -0
  69. pi_durable/testing/runner.py +64 -0
  70. pi_durable/testing/storage_conformance.py +2008 -0
  71. pi_durable/testing/types.py +99 -0
  72. pi_durable/tool_search/__init__.py +159 -0
  73. pi_durable/tools/__init__.py +81 -0
  74. pi_durable/tools/_values.py +43 -0
  75. pi_durable/tools/bash.py +250 -0
  76. pi_durable/tools/edit.py +214 -0
  77. pi_durable/tools/edit_diff.py +452 -0
  78. pi_durable/tools/env.py +19 -0
  79. pi_durable/tools/file_mutation_queue.py +68 -0
  80. pi_durable/tools/image.py +248 -0
  81. pi_durable/tools/image_processor.py +77 -0
  82. pi_durable/tools/path_utils.py +111 -0
  83. pi_durable/tools/read.py +506 -0
  84. pi_durable/tools/write.py +71 -0
  85. pi_durable/truncate.py +185 -0
  86. pi_durable/types.py +1297 -0
  87. pi_durable_python-1.3.0.dist-info/METADATA +276 -0
  88. pi_durable_python-1.3.0.dist-info/RECORD +90 -0
  89. pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
  90. pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
@@ -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"]