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,467 @@
1
+ """Bounded output streams and adaptive progress commits.
2
+
3
+ Port of Chord 1.3.0 ``output.ts``, the module the upstream tool task uses to bound a call's
4
+ running output and to pace its progress commits. ``OutputBuffer`` keeps a head or tail window of a
5
+ stream at a cost proportional to each chunk, and ``Progress`` paces commits of a changing value by
6
+ their written size.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import codecs
13
+ import re
14
+ import time
15
+ from collections.abc import Callable, Mapping
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ from ..delta import overlap_strings
20
+
21
+ #: ``truncate.ts`` defaults shared by tools: whichever limit is hit first wins.
22
+ DEFAULT_MAX_LINES = 2000
23
+ DEFAULT_MAX_BYTES = 50 * 1024
24
+
25
+ #: Each progress commit also buys a pause proportional to what it wrote.
26
+ PROGRESS_BYTES_PER_SECOND = 100 * 1024
27
+
28
+ _INVALID_OUTPUT = re.compile("[\x00-\x08\x0b-\x1f\ufff9-\ufffb]")
29
+ _SURROGATE = re.compile("[\ud800-\udfff]")
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class OutputLimits:
34
+ """Retention limits of one output stream."""
35
+
36
+ max_bytes: int = DEFAULT_MAX_BYTES
37
+ max_lines: int = DEFAULT_MAX_LINES
38
+ retain: str = "head"
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class BoundedOutput:
43
+ """Retained output and what the limits dropped."""
44
+
45
+ text: str
46
+ dropped_bytes: int
47
+ dropped_lines: int
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class OutputSlice:
52
+ """An exact slice of the input within the limits, and what it left out."""
53
+
54
+ text: str
55
+ bytes: int
56
+ dropped_bytes: int
57
+ dropped_lines: int
58
+
59
+
60
+ def sanitize_output(text: str) -> str:
61
+ """Remove control characters that break display and transcripts; tabs and newlines stay."""
62
+
63
+ return _INVALID_OUTPUT.sub("", text)
64
+
65
+
66
+ def utf8_byte_length(text: str) -> int:
67
+ """UTF-8 byte length of ``text``; a lone surrogate counts as U+FFFD, three bytes."""
68
+
69
+ if text.isascii():
70
+ return len(text)
71
+ return len(_encode(text))
72
+
73
+
74
+ def _encode(text: str) -> bytes:
75
+ if _SURROGATE.search(text) is None:
76
+ return text.encode("utf-8")
77
+ return _SURROGATE.sub("\ufffd", text).encode("utf-8")
78
+
79
+
80
+ def _decode(data: bytes) -> str:
81
+ return data.decode("utf-8", "replace")
82
+
83
+
84
+ def bound_output(text: str, limits: OutputLimits) -> OutputSlice:
85
+ """Bound ``text`` to whole lines within the limits: the first lines for ``head``, the last for ``tail``.
86
+
87
+ The result is an exact slice, trailing newline included. A single line longer than ``max_bytes``
88
+ is cut at the byte limit on a character boundary.
89
+ """
90
+
91
+ data = _encode(text)
92
+ start, end = _head_range(data, limits) if limits.retain == "head" else _tail_range(data, limits)
93
+ kept = data[start:end]
94
+ return OutputSlice(
95
+ text=text if len(kept) == len(data) else _decode(kept),
96
+ bytes=len(kept),
97
+ dropped_bytes=len(data) - len(kept),
98
+ dropped_lines=_line_count(data) - _line_count(kept),
99
+ )
100
+
101
+
102
+ def _head_range(data: bytes, limits: OutputLimits) -> tuple[int, int]:
103
+ if limits.max_lines == 0 or limits.max_bytes == 0:
104
+ return 0, 0
105
+ end = len(data)
106
+ lines = 0
107
+ index = data.find(b"\n")
108
+ while index != -1:
109
+ lines += 1
110
+ if lines == limits.max_lines:
111
+ end = index + 1
112
+ break
113
+ index = data.find(b"\n", index + 1)
114
+ if end > limits.max_bytes:
115
+ newline = data.rfind(b"\n", 0, limits.max_bytes)
116
+ end = _character_end(data, limits.max_bytes) if newline == -1 else newline + 1
117
+ return 0, end
118
+
119
+
120
+ def _tail_range(data: bytes, limits: OutputLimits) -> tuple[int, int]:
121
+ if limits.max_lines == 0 or limits.max_bytes == 0:
122
+ return len(data), len(data)
123
+ # A trailing newline ends the last line rather than starting another.
124
+ last = len(data) - 2 if data.endswith(b"\n") else len(data) - 1
125
+ start = 0
126
+ lines = 1
127
+ index = -1 if last < 0 else data.rfind(b"\n", 0, last + 1)
128
+ while index != -1:
129
+ if lines == limits.max_lines:
130
+ start = index + 1
131
+ break
132
+ lines += 1
133
+ index = -1 if index == 0 else data.rfind(b"\n", 0, index)
134
+ if len(data) - start > limits.max_bytes:
135
+ first = len(data) - limits.max_bytes
136
+ newline = data.find(b"\n", max(0, first - 1))
137
+ # The first line starting inside the byte window, or a cut of the last line when it alone
138
+ # is too long.
139
+ start = newline + 1 if newline != -1 and newline + 1 < len(data) else _character_start(data, first)
140
+ return start, len(data)
141
+
142
+
143
+ def _character_end(data: bytes, index: int) -> int:
144
+ """The last character boundary at or before ``index``."""
145
+
146
+ end = index
147
+ while end > 0 and (data[end] & 0xC0) == 0x80:
148
+ end -= 1
149
+ return end
150
+
151
+
152
+ def _character_start(data: bytes, index: int) -> int:
153
+ """The first character boundary at or after ``index``."""
154
+
155
+ start = index
156
+ while start < len(data) and (data[start] & 0xC0) == 0x80:
157
+ start += 1
158
+ return start
159
+
160
+
161
+ def _line_count(data: bytes) -> int:
162
+ if not data:
163
+ return 0
164
+ newlines = data.count(b"\n")
165
+ return newlines + (0 if data.endswith(b"\n") else 1)
166
+
167
+
168
+ class OutputBuffer:
169
+ """Bounded running output of one stream, such as a tool call's.
170
+
171
+ Accepting a chunk costs time proportional to the chunk: head retention stops storing once the
172
+ window is full, and tail retention drops stored text the window no longer needs when it
173
+ snapshots. Counts of the whole stream are kept so the dropped totals stay exact.
174
+ """
175
+
176
+ def __init__(self, limits: OutputLimits) -> None:
177
+ self._limits = limits
178
+ self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
179
+ self._started = False
180
+ # Stored chunks: for head the start of the stream, for tail a suffix that still contains the
181
+ # next window.
182
+ self._chunks: list[tuple[str, int, int]] = []
183
+ self._stored_bytes = 0
184
+ self._stored_newlines = 0
185
+ self._full = False
186
+ self._total_bytes = 0
187
+ self._total_newlines = 0
188
+ self._ends_with_newline = True
189
+
190
+ @property
191
+ def stored_bytes(self) -> int:
192
+ """Bytes currently held; bounded by the limits plus one chunk."""
193
+
194
+ return self._stored_bytes
195
+
196
+ def push(self, chunk: str | bytes, skipped: Mapping[str, Any] | None = None) -> bool:
197
+ """Accept a chunk; return whether anything was accepted.
198
+
199
+ ``skipped`` is output omitted right before the chunk, which must be more than the tail
200
+ window by at least one byte or line; only tail retention accepts it.
201
+ """
202
+
203
+ # Bytes of an incomplete character from an earlier byte chunk come first.
204
+ pending = self._decoder.decode(b"") if isinstance(chunk, str) or skipped is not None else ""
205
+ text = chunk if isinstance(chunk, str) else self._decoder.decode(chunk, False)
206
+ first = not self._started and pending == "" and skipped is None
207
+ if pending != "" or text != "" or skipped is not None:
208
+ self._started = True
209
+ if first and isinstance(chunk, bytes) and text.startswith("\ufeff"):
210
+ text = text[1:]
211
+ if skipped is None:
212
+ return self._accept(pending + text)
213
+ if self._limits.retain != "tail":
214
+ raise ValueError("Skipped output requires tail retention")
215
+ self._accept(pending)
216
+ self._skip(_skip_of(skipped))
217
+ self._accept(text)
218
+ return True
219
+
220
+ def _skip(self, skipped: OutputSkip) -> None:
221
+ if skipped.byte_count == 0:
222
+ return
223
+ self._total_bytes += skipped.byte_count
224
+ self._total_newlines += skipped.newlines
225
+ self._ends_with_newline = skipped.ends_with_newline
226
+ self._chunks = []
227
+ self._stored_bytes = 0
228
+ self._stored_newlines = 0
229
+
230
+ def end(self) -> None:
231
+ """Flush an incomplete trailing character as a replacement character; call when the stream ends."""
232
+
233
+ self._accept(self._decoder.decode(b"", True))
234
+
235
+ def _accept(self, text: str) -> bool:
236
+ if text == "":
237
+ return False
238
+ bytes_ = utf8_byte_length(text)
239
+ newlines = text.count("\n")
240
+ self._total_bytes += bytes_
241
+ self._total_newlines += newlines
242
+ self._ends_with_newline = text.endswith("\n")
243
+ if self._full:
244
+ return True
245
+ self._chunks.append((text, bytes_, newlines))
246
+ self._stored_bytes += bytes_
247
+ self._stored_newlines += newlines
248
+ if self._limits.retain == "head":
249
+ # Nothing past a full window is ever needed.
250
+ self._full = (
251
+ self._stored_bytes > self._limits.max_bytes
252
+ or self._stored_newlines >= self._limits.max_lines
253
+ )
254
+ return True
255
+ # Drop leading chunks while the rest still holds more than a window: more than max_bytes
256
+ # bytes or max_lines newlines, plus one, so the window's line start can still be found.
257
+ while len(self._chunks) > 1:
258
+ first_bytes, first_newlines = self._chunks[0][1], self._chunks[0][2]
259
+ bytes_after = self._stored_bytes - first_bytes
260
+ newlines_after = self._stored_newlines - first_newlines
261
+ if bytes_after <= self._limits.max_bytes + 1 and newlines_after <= self._limits.max_lines + 1:
262
+ break
263
+ self._chunks.pop(0)
264
+ self._stored_bytes = bytes_after
265
+ self._stored_newlines = newlines_after
266
+ return True
267
+
268
+ def snapshot(self) -> BoundedOutput:
269
+ """Retained, sanitized output and what the limits dropped from the whole stream."""
270
+
271
+ stored = self._chunks[0][0] if len(self._chunks) == 1 else "".join(chunk[0] for chunk in self._chunks)
272
+ kept = bound_output(stored, self._limits)
273
+ stored_lines = _lines(self._stored_newlines, stored == "" or stored.endswith("\n"))
274
+ kept_lines = stored_lines - kept.dropped_lines
275
+ # Tail windows never reach back before this one, but finding a later window's first line needs
276
+ # what precedes it: keep the shortest suffix longer than the window by a byte or a line.
277
+ if self._limits.retain == "tail" or len(self._chunks) > 1:
278
+ text = _tail_margin(stored, self._limits) if self._limits.retain == "tail" else stored
279
+ bytes_ = utf8_byte_length(text) if self._limits.retain == "tail" else self._stored_bytes
280
+ self._chunks = [] if text == "" else [(text, bytes_, text.count("\n"))]
281
+ self._stored_bytes = bytes_
282
+ self._stored_newlines = self._chunks[0][2] if self._chunks else 0
283
+ return BoundedOutput(
284
+ text=sanitize_output(kept.text),
285
+ dropped_bytes=self._total_bytes - kept.bytes,
286
+ dropped_lines=_lines(self._total_newlines, self._ends_with_newline) - kept_lines,
287
+ )
288
+
289
+
290
+ @dataclass(frozen=True, slots=True)
291
+ class OutputSkip:
292
+ """Output a producer omitted before a chunk, measured on the decoded text."""
293
+
294
+ byte_count: int
295
+ newlines: int
296
+ ends_with_newline: bool
297
+
298
+
299
+ def _skip_of(skipped: Mapping[str, Any]) -> OutputSkip:
300
+ bytes_ = skipped.get("bytes", skipped.get("byteCount", 0))
301
+ newlines = skipped.get("newlines", 0)
302
+ ends = skipped.get("ends_with_newline", skipped.get("endsWithNewline", False))
303
+ return OutputSkip(int(bytes_ or 0), int(newlines or 0), bool(ends))
304
+
305
+
306
+ def _tail_margin(text: str, limits: OutputLimits) -> str:
307
+ """The shortest suffix of ``text`` with more than ``max_bytes`` bytes or ``max_lines`` newlines."""
308
+
309
+ data = _encode(text)
310
+ byte_start = (
311
+ _character_end(data, len(data) - limits.max_bytes - 1) if len(data) > limits.max_bytes else 0
312
+ )
313
+ line_start = 0
314
+ newlines = 0
315
+ index = data.rfind(b"\n")
316
+ while index != -1:
317
+ newlines += 1
318
+ if newlines > limits.max_lines:
319
+ line_start = index
320
+ break
321
+ if index == 0:
322
+ break
323
+ index = data.rfind(b"\n", 0, index)
324
+ start = max(byte_start, line_start)
325
+ return text if start == 0 else _decode(data[start:])
326
+
327
+
328
+ def _lines(newlines: int, terminated: bool) -> int:
329
+ return newlines + (0 if terminated else 1)
330
+
331
+
332
+ def overlap(left: str, right: str, limit: int) -> int:
333
+ """Length of the longest suffix of ``left`` that is a prefix of ``right``, bounded by ``limit``."""
334
+
335
+ return overlap_strings(left, right, limit)
336
+
337
+
338
+ def _now_ms() -> float:
339
+ return time.monotonic() * 1000.0
340
+
341
+
342
+ class Progress:
343
+ """Adaptive progress commits: the first change after an idle period commits at once; each commit
344
+ then delays the next by at least ``min_interval_ms`` and by its written size at 100 KiB/s.
345
+
346
+ At most one commit is in flight; changes made meanwhile coalesce into the next one.
347
+ """
348
+
349
+ def __init__(
350
+ self,
351
+ write: Callable[[], Any],
352
+ on_error: Callable[[BaseException], None],
353
+ min_interval_ms: int,
354
+ ) -> None:
355
+ self._write = write
356
+ self._on_error = on_error
357
+ self._min_interval_ms = max(0, min_interval_ms)
358
+ self._waiters: list[asyncio.Future[None]] = []
359
+ self._timer: asyncio.TimerHandle | None = None
360
+ self._in_flight: asyncio.Future[None] | None = None
361
+ self._next_at = 0.0
362
+ self._dirty = False
363
+ self._stopped = False
364
+
365
+ def mark(self) -> None:
366
+ """Schedule a commit."""
367
+
368
+ self._dirty = True
369
+ self._schedule()
370
+
371
+ def mark_and_wait(self) -> asyncio.Future[None]:
372
+ """Schedule a commit; the future settles with the commit that includes this change."""
373
+
374
+ waiter: asyncio.Future[None] = asyncio.get_running_loop().create_future()
375
+ self._waiters.append(waiter)
376
+ self.mark()
377
+ return waiter
378
+
379
+ async def stop(self) -> list[asyncio.Future[None]]:
380
+ """Stop committing and wait for the commit in flight; returns the waiters the final commit settles."""
381
+
382
+ self._stopped = True
383
+ if self._timer is not None:
384
+ self._timer.cancel()
385
+ self._timer = None
386
+ if self._in_flight is not None:
387
+ await self._in_flight
388
+ pending = self._waiters
389
+ self._waiters = []
390
+ return pending
391
+
392
+ def _schedule(self) -> None:
393
+ if self._stopped or self._timer is not None or self._in_flight is not None:
394
+ return
395
+ wait = self._next_at - _now_ms()
396
+ if wait <= 0:
397
+ self._flush()
398
+ else:
399
+ loop = asyncio.get_running_loop()
400
+ self._timer = loop.call_later(wait / 1000.0, self._fire)
401
+
402
+ def _fire(self) -> None:
403
+ self._timer = None
404
+ self._flush()
405
+
406
+ def _flush(self) -> None:
407
+ if self._stopped or not self._dirty:
408
+ return
409
+ self._dirty = False
410
+ waiters = self._waiters
411
+ self._waiters = []
412
+ started = _now_ms()
413
+ # Called synchronously, so a writer captures what changed before the commit is awaited.
414
+ try:
415
+ produced = self._write()
416
+ except BaseException as error:
417
+ self._fail(error, started, waiters)
418
+ return
419
+
420
+ async def complete() -> None:
421
+ try:
422
+ written = await produced if hasattr(produced, "__await__") else produced
423
+ except BaseException as error:
424
+ self._fail(error, started, waiters)
425
+ return
426
+ bytes_ = int(written or 0)
427
+ self._next_at = started + max(
428
+ self._min_interval_ms, bytes_ * 1000.0 / PROGRESS_BYTES_PER_SECOND
429
+ )
430
+ for waiter in waiters:
431
+ if not waiter.done():
432
+ waiter.set_result(None)
433
+
434
+ self._in_flight = asyncio.ensure_future(complete())
435
+ self._in_flight.add_done_callback(lambda _: None)
436
+
437
+ def finished(_: Any) -> None:
438
+ self._in_flight = None
439
+ if self._dirty:
440
+ self._schedule()
441
+
442
+ self._in_flight.add_done_callback(finished)
443
+
444
+ def _fail(self, error: BaseException, started: float, waiters: list[asyncio.Future[None]]) -> None:
445
+ self._next_at = started + self._min_interval_ms
446
+ for waiter in waiters:
447
+ if not waiter.done():
448
+ waiter.set_exception(error)
449
+ waiter.exception()
450
+ self._on_error(error)
451
+
452
+
453
+ __all__ = [
454
+ "DEFAULT_MAX_BYTES",
455
+ "DEFAULT_MAX_LINES",
456
+ "PROGRESS_BYTES_PER_SECOND",
457
+ "BoundedOutput",
458
+ "OutputBuffer",
459
+ "OutputLimits",
460
+ "OutputSkip",
461
+ "OutputSlice",
462
+ "Progress",
463
+ "bound_output",
464
+ "overlap",
465
+ "sanitize_output",
466
+ "utf8_byte_length",
467
+ ]