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,362 @@
1
+ """Provider-facing model protocol and the shared pi-ai helpers generation reads.
2
+
3
+ The port replaces pi-ai with a small ``Models`` protocol. The OpenAI and Anthropic adapters in the
4
+ optional extras implement it; the generation task only needs model lookup, a streaming request, a
5
+ non-streaming request, and the deferred-response calls. The classifiers mirror the pi-ai retry and
6
+ overflow utilities generation uses to decide an attempt's outcome.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import math
13
+ import re
14
+ from collections.abc import AsyncIterator, Awaitable, Mapping, Sequence
15
+ from dataclasses import dataclass
16
+ from typing import Any, Protocol, cast, runtime_checkable
17
+
18
+ DEFAULT_MAX_AGENT_RETRY_DELAY_MS = 60_000
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Model:
23
+ """One provider model's identity, limits, and input capabilities."""
24
+
25
+ provider: str
26
+ model_id: str
27
+ context_window: int = 0
28
+ max_tokens: int = 0
29
+ reasoning: bool = True
30
+ input: tuple[str, ...] = ("text", "image")
31
+ """What the model takes inline; ``"image"`` enables the ``read`` image placeholder rule."""
32
+
33
+ input_limits: Mapping[str, Any] | None = None
34
+ """Per-input limits, such as ``{"images": {"resize": {"max_width": 2000}}}``."""
35
+
36
+ cost: Mapping[str, float] | None = None
37
+ """Flat USD per million tokens: ``input``, ``output``, ``cacheRead``, and ``cacheWrite``."""
38
+
39
+
40
+ class AssistantMessageStream(Protocol):
41
+ """A pi-ai style stream: partial events while it runs, then the terminal message."""
42
+
43
+ def __aiter__(self) -> AsyncIterator[dict[str, Any]]: ...
44
+
45
+ def result(self) -> Awaitable[dict[str, Any]]: ...
46
+
47
+
48
+ @runtime_checkable
49
+ class Models(Protocol):
50
+ """The model access generation and compaction use."""
51
+
52
+ def get_model(self, provider: str, model_id: str) -> Model | None: ...
53
+
54
+ def stream_simple(
55
+ self,
56
+ model: Model,
57
+ context: Mapping[str, Any],
58
+ options: Mapping[str, Any] | None = None,
59
+ ) -> AssistantMessageStream: ...
60
+
61
+ def complete_simple(
62
+ self,
63
+ model: Model,
64
+ context: Mapping[str, Any],
65
+ options: Mapping[str, Any] | None = None,
66
+ ) -> Awaitable[dict[str, Any]]: ...
67
+
68
+ def stream(
69
+ self,
70
+ model: Model,
71
+ context: Mapping[str, Any],
72
+ options: Mapping[str, Any] | None = None,
73
+ ) -> AssistantMessageStream: ...
74
+
75
+ def complete(
76
+ self,
77
+ model: Model,
78
+ context: Mapping[str, Any],
79
+ options: Mapping[str, Any] | None = None,
80
+ ) -> Awaitable[dict[str, Any]]: ...
81
+
82
+ def fetch_deferred(
83
+ self,
84
+ model: Model,
85
+ handle: Mapping[str, Any],
86
+ options: Mapping[str, Any] | None = None,
87
+ ) -> Awaitable[dict[str, Any]]: ...
88
+
89
+ def cancel_deferred(
90
+ self,
91
+ model: Model,
92
+ handle: Mapping[str, Any],
93
+ options: Mapping[str, Any] | None = None,
94
+ ) -> Awaitable[None]: ...
95
+
96
+
97
+ # ── Usage and token estimates ───────────────────────────────────────────────
98
+
99
+ CHARS_PER_TOKEN = 3.5
100
+ ESTIMATED_IMAGE_CHARS = 4800
101
+
102
+
103
+ def calculate_context_tokens(usage: Mapping[str, Any]) -> int:
104
+ """The tokens a response's usage counts against its context window."""
105
+
106
+ total = usage.get("totalTokens")
107
+ if total:
108
+ return int(total)
109
+ return (
110
+ int(usage.get("input", 0))
111
+ + int(usage.get("output", 0))
112
+ + int(usage.get("cacheRead", 0))
113
+ + int(usage.get("cacheWrite", 0))
114
+ )
115
+
116
+
117
+ def estimate_text_tokens(text: str) -> int:
118
+ """pi-ai's text estimate: one token per 3.5 characters, rounded up."""
119
+
120
+ return math.ceil(len(text) / CHARS_PER_TOKEN)
121
+
122
+
123
+ def estimate_text_and_image_content_tokens(content: Any) -> int:
124
+ """Estimate a user or tool-result content: text characters plus a fixed cost per image."""
125
+
126
+ return math.ceil(_content_chars(content) / CHARS_PER_TOKEN)
127
+
128
+
129
+ def estimate_message_tokens(message: Mapping[str, Any]) -> int:
130
+ """pi-ai's ``estimateMessageTokens()``, the estimate range selection and thresholds use."""
131
+
132
+ role = message.get("role")
133
+ if role == "system":
134
+ return (
135
+ estimate_text_tokens(_system_message_text(message))
136
+ + _estimate_tools_tokens(message.get("toolsAdded"))
137
+ + _estimate_tools_tokens(message.get("toolsRemoved"))
138
+ )
139
+ if role in ("user", "toolResult"):
140
+ return estimate_text_and_image_content_tokens(message.get("content"))
141
+ chars = 0
142
+ for block in cast(Sequence[Mapping[str, Any]], message.get("content") or ()):
143
+ kind = block.get("type")
144
+ if kind == "text":
145
+ chars += len(cast(str, block.get("text") or ""))
146
+ elif kind == "thinking":
147
+ chars += len(cast(str, block.get("thinking") or ""))
148
+ else:
149
+ chars += len(cast(str, block.get("name") or "")) + len(_safe_json(block.get("arguments")))
150
+ return math.ceil(chars / CHARS_PER_TOKEN)
151
+
152
+
153
+ def _content_chars(content: Any) -> int:
154
+ if isinstance(content, str):
155
+ return len(content)
156
+ chars = 0
157
+ for block in cast(Sequence[Mapping[str, Any]], content or ()):
158
+ if block.get("type") == "text":
159
+ chars += len(cast(str, block.get("text") or ""))
160
+ else:
161
+ chars += ESTIMATED_IMAGE_CHARS
162
+ return chars
163
+
164
+
165
+ def _system_message_text(message: Mapping[str, Any]) -> str:
166
+ """A system message as a complete prompt: its content followed by its sections."""
167
+
168
+ # Imported lazily because the transcript helpers live above ``Model`` in the package graph.
169
+ from .transcript import system_message_text
170
+
171
+ return system_message_text(message)
172
+
173
+
174
+ def _estimate_tools_tokens(tools: Any) -> int:
175
+ if not tools:
176
+ return 0
177
+ return estimate_text_tokens(_safe_json(tools))
178
+
179
+
180
+ def _safe_json(value: Any) -> str:
181
+ try:
182
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
183
+ except (TypeError, ValueError):
184
+ return "[unserializable]"
185
+
186
+
187
+ # ── Retry classification ────────────────────────────────────────────────────
188
+
189
+ _NON_RETRYABLE = re.compile(
190
+ "|".join(
191
+ [
192
+ "GoUsageLimitError",
193
+ "FreeUsageLimitError",
194
+ "Monthly usage limit reached",
195
+ "available balance",
196
+ "insufficient_quota",
197
+ "out of budget",
198
+ "quota exceeded",
199
+ "billing",
200
+ "subscription_sharing_usage_limit_exceeded",
201
+ ]
202
+ ),
203
+ re.IGNORECASE,
204
+ )
205
+
206
+ _RETRYABLE = re.compile(
207
+ "|".join(
208
+ [
209
+ "overloaded",
210
+ "server_busy",
211
+ "servers are currently busy",
212
+ "currently experiencing high demand",
213
+ "model is at capacity",
214
+ "rate.?limit",
215
+ "too many requests",
216
+ "429",
217
+ "500",
218
+ "502",
219
+ "503",
220
+ "504",
221
+ "520",
222
+ "524",
223
+ "service.?(?:is.?)?(?:temporarily.?)?unavailable",
224
+ "server.?error",
225
+ "internal.?error",
226
+ "provider.?returned.?(?:an.?)?error",
227
+ "exceeded request buffer limit while retrying upstream",
228
+ "unable to forward upstream",
229
+ "network.?error",
230
+ "connection.?error",
231
+ "connection.?refused",
232
+ "connection.?lost",
233
+ "other side closed",
234
+ "fetch failed",
235
+ "getaddrinfo",
236
+ "ENOTFOUND",
237
+ "EAI_AGAIN",
238
+ "upstream.?connect",
239
+ "reset before headers",
240
+ "socket hang up",
241
+ "socket connection was closed",
242
+ "timed? out",
243
+ "timeout",
244
+ "terminated",
245
+ "websocket.?closed",
246
+ "websocket.?error",
247
+ "ended without",
248
+ "stream ended before message_stop",
249
+ "stream ended before a terminal response event",
250
+ "http2 request did not get a response",
251
+ "pending stream has been canceled",
252
+ "retry delay",
253
+ "you can retry your request",
254
+ "try your request again",
255
+ "please retry your request",
256
+ "ResourceExhausted",
257
+ "subscription_sharing_usage_unavailable",
258
+ "subscription_sharing_user_unavailable",
259
+ ]
260
+ ),
261
+ re.IGNORECASE,
262
+ )
263
+
264
+
265
+ def is_retryable_assistant_error(message: Mapping[str, Any]) -> bool:
266
+ """Whether a failed assistant message looks like a transient provider or transport error."""
267
+
268
+ if message.get("stopReason") != "error":
269
+ return False
270
+ text = message.get("errorMessage")
271
+ if not isinstance(text, str) or not text:
272
+ return False
273
+ if _NON_RETRYABLE.search(text):
274
+ return False
275
+ return _RETRYABLE.search(text) is not None
276
+
277
+
278
+ def retry_delay_ms(policy: Mapping[str, Any], attempt: int) -> int:
279
+ """Exponential backoff ``baseDelayMs * 2 ** (attempt - 1)``, capped at 60 seconds."""
280
+
281
+ base = int(policy.get("base_delay_ms", 2_000))
282
+ delay = base * (2 ** max(0, attempt - 1))
283
+ cap = int(policy.get("max_agent_delay_ms", DEFAULT_MAX_AGENT_RETRY_DELAY_MS))
284
+ return min(delay, cap)
285
+
286
+
287
+ # ── Overflow classification ─────────────────────────────────────────────────
288
+
289
+ _OVERFLOW = [
290
+ re.compile(pattern, re.IGNORECASE)
291
+ for pattern in (
292
+ r"prompt (?:is )?too long",
293
+ r"prompt exceeds max length",
294
+ r"request_too_large",
295
+ r"input is too long for requested model",
296
+ r"exceeds the context window",
297
+ r"exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))?",
298
+ r"input token count.*exceeds the maximum",
299
+ r"maximum prompt length is \d+",
300
+ r"reduce the length of the messages",
301
+ r"maximum context length is \d+ tokens",
302
+ r"exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?",
303
+ r"input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)",
304
+ r"exceeds the limit of \d+",
305
+ r"exceeds the available context size",
306
+ r"greater than the context length",
307
+ r"context window exceeds limit",
308
+ r"exceeded model token limit",
309
+ r"too large for model with \d+ maximum context length",
310
+ r"prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?",
311
+ r"model_context_window_exceeded",
312
+ r"prompt too long; exceeded (?:max )?context length",
313
+ r"range of input length should be",
314
+ r"context[_ ]length[_ ]exceeded",
315
+ r"too many tokens",
316
+ r"token limit exceeded",
317
+ )
318
+ ]
319
+ _NON_OVERFLOW = [
320
+ re.compile(pattern, re.IGNORECASE)
321
+ for pattern in (
322
+ r"^(Throttling error|Service unavailable):",
323
+ r"rate limit",
324
+ r"too many requests",
325
+ )
326
+ ]
327
+ _CEREBRAS_BODYLESS = re.compile(r"^4(?:00|13)\s*(?:status code)?\s*\(no body\)", re.IGNORECASE)
328
+
329
+
330
+ def is_context_overflow(message: Mapping[str, Any], context_window: int | None = None) -> bool:
331
+ """Whether an assistant message reports or silently shows a context overflow."""
332
+
333
+ text = message.get("errorMessage")
334
+ if message.get("stopReason") == "error" and isinstance(text, str) and text:
335
+ if not any(pattern.search(text) for pattern in _NON_OVERFLOW):
336
+ if any(pattern.search(text) for pattern in _OVERFLOW):
337
+ return True
338
+ if message.get("provider") == "cerebras" and _CEREBRAS_BODYLESS.search(text):
339
+ return True
340
+ usage_value = message.get("usage") or {}
341
+ input_tokens = int(usage_value.get("input", 0)) + int(usage_value.get("cacheRead", 0))
342
+ if context_window and message.get("stopReason") == "stop" and input_tokens > context_window:
343
+ return True
344
+ if (
345
+ context_window
346
+ and message.get("stopReason") == "length"
347
+ and int(usage_value.get("output", 0)) == 0
348
+ and input_tokens >= context_window * 0.99
349
+ ):
350
+ return True
351
+ return False
352
+
353
+
354
+ __all__ = [
355
+ "DEFAULT_MAX_AGENT_RETRY_DELAY_MS",
356
+ "AssistantMessageStream",
357
+ "Model",
358
+ "Models",
359
+ "is_context_overflow",
360
+ "is_retryable_assistant_error",
361
+ "retry_delay_ms",
362
+ ]