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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Built-in ``pi.usage``: the ledger of one conversation's own spend.
|
|
2
|
+
|
|
3
|
+
Port of v1.3.0 ``src/harness/usage.ts``. Every built-in writer of an assistant or tool-result entry
|
|
4
|
+
adds that message's usage in the same commit; compaction summarization attempts add theirs without
|
|
5
|
+
an entry. ``Harness.usage()`` sums every conversation's document into the Session total.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping, MutableMapping
|
|
11
|
+
from typing import Any, TypedDict, cast
|
|
12
|
+
|
|
13
|
+
from ..documents import define_doc
|
|
14
|
+
from ..ids import ConversationId
|
|
15
|
+
from ..json import JsonValue, copy_json
|
|
16
|
+
from ..session.transaction import Transaction
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UsageState(TypedDict):
|
|
20
|
+
"""Spend keyed by ``provider/modelId`` and by tool name."""
|
|
21
|
+
|
|
22
|
+
models: dict[str, dict[str, JsonValue]]
|
|
23
|
+
tools: dict[str, dict[str, JsonValue]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
UsageDoc = define_doc(
|
|
27
|
+
{
|
|
28
|
+
"kind": "pi.usage",
|
|
29
|
+
"version": 1,
|
|
30
|
+
"scope": "conversation",
|
|
31
|
+
"history": "latest",
|
|
32
|
+
"fork": "initial",
|
|
33
|
+
"initial": lambda: {"models": {}, "tools": {}},
|
|
34
|
+
"checkpoint_when": lambda _state, _ops, _info: True,
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def record_usage(
|
|
40
|
+
tx: Transaction,
|
|
41
|
+
conversation_id: ConversationId,
|
|
42
|
+
bucket: str,
|
|
43
|
+
key: str,
|
|
44
|
+
usage: Mapping[str, Any],
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Add ``usage`` to one bucket of the conversation's ``pi.usage``."""
|
|
47
|
+
|
|
48
|
+
totals = (await tx.doc(UsageDoc, conversation_id))[bucket]
|
|
49
|
+
current = totals.get(key) if hasattr(totals, "get") else None
|
|
50
|
+
if current is None:
|
|
51
|
+
totals[key] = _strict_usage(usage)
|
|
52
|
+
else:
|
|
53
|
+
add_usage(cast(MutableMapping[str, Any], current), usage)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def add_usage(total: MutableMapping[str, Any], usage: Mapping[str, Any]) -> None:
|
|
57
|
+
"""Add every counter of ``usage`` to ``total``; optional counters add once either side reports them."""
|
|
58
|
+
|
|
59
|
+
for key in ("input", "output", "cacheRead", "cacheWrite", "totalTokens"):
|
|
60
|
+
total[key] = int(total.get(key, 0)) + int(usage.get(key, 0))
|
|
61
|
+
for key in ("cacheWrite1h", "reasoning"):
|
|
62
|
+
if usage.get(key) is not None:
|
|
63
|
+
total[key] = int(total.get(key, 0)) + int(usage[key])
|
|
64
|
+
if "cost" not in total:
|
|
65
|
+
total["cost"] = {}
|
|
66
|
+
total_cost = cast(MutableMapping[str, Any], total["cost"])
|
|
67
|
+
usage_cost = cast(Mapping[str, Any], usage.get("cost") or {})
|
|
68
|
+
for key in ("input", "output", "cacheRead", "cacheWrite", "total"):
|
|
69
|
+
total_cost[key] = total_cost.get(key, 0) + usage_cost.get(key, 0)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def add_usage_state(sum_value: UsageState, state: Mapping[str, Any]) -> None:
|
|
73
|
+
"""Add every bucket of ``state`` into ``sum``."""
|
|
74
|
+
|
|
75
|
+
for bucket in ("models", "tools"):
|
|
76
|
+
destination = sum_value[bucket]
|
|
77
|
+
for key, value in cast(Mapping[str, Any], state.get(bucket) or {}).items():
|
|
78
|
+
if key in destination:
|
|
79
|
+
add_usage(cast(MutableMapping[str, Any], destination[key]), value)
|
|
80
|
+
else:
|
|
81
|
+
destination[key] = cast(dict[str, JsonValue], copy_json(value))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _strict_usage(usage: Mapping[str, Any]) -> dict[str, JsonValue]:
|
|
85
|
+
return cast(dict[str, JsonValue], copy_json(dict(usage)))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
__all__ = ["UsageDoc", "UsageState", "add_usage", "add_usage_state", "record_usage"]
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Scheduler support: pending waits, paginated scans, and closed-Harness errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
from collections.abc import Awaitable, Callable, Hashable, Mapping
|
|
8
|
+
from typing import Any, Generic, TypeVar, cast
|
|
9
|
+
|
|
10
|
+
from ..context import AbortError, AbortSignal, Context
|
|
11
|
+
from ..errors import SessionFailed
|
|
12
|
+
from ..json import JsonObject
|
|
13
|
+
from ..types import Cursor, Page, TaskRecord
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
K = TypeVar("K", bound=Hashable)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cancellation_error(signal: AbortSignal) -> BaseException:
|
|
20
|
+
"""Return what an aborted context rejects with: its reason if it is an error, else ``AbortError``."""
|
|
21
|
+
|
|
22
|
+
reason = signal.reason
|
|
23
|
+
if isinstance(reason, BaseException):
|
|
24
|
+
return reason
|
|
25
|
+
return AbortError(reason)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Waiters(Generic[K, T]):
|
|
29
|
+
"""Pending waits by key. Each settles once: through ``resolve``, ``reject_all``, or its cancellation."""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self._sets: dict[K, set[asyncio.Future[T]]] = {}
|
|
33
|
+
|
|
34
|
+
def add(self, key: K, context: Context) -> Awaitable[T]:
|
|
35
|
+
signal = context.abort_signal
|
|
36
|
+
if signal is not None and signal.aborted:
|
|
37
|
+
return rejected(cancellation_error(signal))
|
|
38
|
+
loop = asyncio.get_running_loop()
|
|
39
|
+
waiter: asyncio.Future[T] = loop.create_future()
|
|
40
|
+
waiter.add_done_callback(lambda future: future.exception() if not future.cancelled() else None)
|
|
41
|
+
self._sets.setdefault(key, set()).add(waiter)
|
|
42
|
+
if signal is not None:
|
|
43
|
+
|
|
44
|
+
def cancel() -> None:
|
|
45
|
+
values = self._sets.get(key)
|
|
46
|
+
if values is not None:
|
|
47
|
+
values.discard(waiter)
|
|
48
|
+
if not values and self._sets.get(key) is values:
|
|
49
|
+
del self._sets[key]
|
|
50
|
+
if not waiter.done():
|
|
51
|
+
waiter.set_exception(cancellation_error(signal))
|
|
52
|
+
|
|
53
|
+
remove = signal.add_listener(cancel)
|
|
54
|
+
waiter.add_done_callback(lambda _: remove())
|
|
55
|
+
return waiter
|
|
56
|
+
|
|
57
|
+
def keys(self) -> list[K]:
|
|
58
|
+
return list(self._sets)
|
|
59
|
+
|
|
60
|
+
def resolve(self, key: K, value: T) -> None:
|
|
61
|
+
values = self._sets.pop(key, None)
|
|
62
|
+
for waiter in values or ():
|
|
63
|
+
if not waiter.done():
|
|
64
|
+
waiter.set_result(value)
|
|
65
|
+
|
|
66
|
+
def reject_all(self, error: BaseException) -> None:
|
|
67
|
+
sets = list(self._sets.values())
|
|
68
|
+
self._sets.clear()
|
|
69
|
+
for values in sets:
|
|
70
|
+
for waiter in values:
|
|
71
|
+
if not waiter.done():
|
|
72
|
+
waiter.set_exception(error)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def rejected(error: BaseException) -> asyncio.Future[Any]:
|
|
76
|
+
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
|
|
77
|
+
future.set_exception(error)
|
|
78
|
+
future.add_done_callback(lambda f: f.exception() if not f.cancelled() else None)
|
|
79
|
+
return future
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolved(value: T) -> asyncio.Future[T]:
|
|
83
|
+
future: asyncio.Future[T] = asyncio.get_running_loop().create_future()
|
|
84
|
+
future.set_result(value)
|
|
85
|
+
return future
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def scan_all(scan: Callable[[Cursor | None], Awaitable[Page[Any, Cursor]]]) -> list[Any]:
|
|
89
|
+
"""Every item of a paginated scan, in page order."""
|
|
90
|
+
|
|
91
|
+
items: list[Any] = []
|
|
92
|
+
cursor: Cursor | None = None
|
|
93
|
+
while True:
|
|
94
|
+
page = await scan(cursor)
|
|
95
|
+
items.extend(page["items"])
|
|
96
|
+
cursor = page.get("next")
|
|
97
|
+
if cursor is None:
|
|
98
|
+
return items
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def closed_error(session: Any) -> BaseException:
|
|
102
|
+
"""What a closed Harness rejects with: ``SessionFailed`` when it closed because the Session failed."""
|
|
103
|
+
|
|
104
|
+
failure = session.failure
|
|
105
|
+
return RuntimeError("Harness is closed") if failure is None else SessionFailed(failure)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def call_with_args(handler: Callable[..., Any], *args: Any) -> Any:
|
|
109
|
+
"""Call ``handler`` with only the arguments it declares, as JavaScript ignores extras."""
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
parameters = list(inspect.signature(handler).parameters.values())
|
|
113
|
+
except (TypeError, ValueError):
|
|
114
|
+
return handler(*args)
|
|
115
|
+
if any(parameter.kind is inspect.Parameter.VAR_POSITIONAL for parameter in parameters):
|
|
116
|
+
return handler(*args)
|
|
117
|
+
positional = [
|
|
118
|
+
parameter
|
|
119
|
+
for parameter in parameters
|
|
120
|
+
if parameter.kind
|
|
121
|
+
in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
122
|
+
]
|
|
123
|
+
return handler(*args[: len(positional)])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def assign_json(target: Any, key: Any, value: Any) -> None:
|
|
127
|
+
"""Assign ``value`` at ``target[key]`` leaf by leaf, so a string leaf becomes an append."""
|
|
128
|
+
|
|
129
|
+
slots = target
|
|
130
|
+
current = slots.get(key) if hasattr(slots, "get") else slots[key]
|
|
131
|
+
if _is_record(current) and _is_record(value):
|
|
132
|
+
for name in list(current.keys()):
|
|
133
|
+
if name not in value:
|
|
134
|
+
del current[name]
|
|
135
|
+
for name, child in value.items():
|
|
136
|
+
assign_json(current, name, child)
|
|
137
|
+
return
|
|
138
|
+
if _is_array(current) and _is_array(value) and len(current) <= len(value):
|
|
139
|
+
for index in range(len(value)):
|
|
140
|
+
if index < len(current):
|
|
141
|
+
assign_json(current, index, value[index])
|
|
142
|
+
else:
|
|
143
|
+
current.append(value[index])
|
|
144
|
+
return
|
|
145
|
+
if current != value:
|
|
146
|
+
slots[key] = value
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _is_record(value: Any) -> bool:
|
|
150
|
+
from ..session.drafts import DraftObject
|
|
151
|
+
|
|
152
|
+
return isinstance(value, (dict, DraftObject))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _is_array(value: Any) -> bool:
|
|
156
|
+
from ..session.drafts import DraftList
|
|
157
|
+
|
|
158
|
+
return isinstance(value, (list, DraftList))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
#: Host-facing settings use PEP 8 names; provider options and persisted checkpoints use the
|
|
162
|
+
#: camelCase pi-ai request shape.
|
|
163
|
+
_STREAM_OPTION_KEYS = {
|
|
164
|
+
"timeout_ms": "timeoutMs",
|
|
165
|
+
"max_retries": "maxRetries",
|
|
166
|
+
"max_retry_delay_ms": "maxRetryDelayMs",
|
|
167
|
+
"cache_retention": "cacheRetention",
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def task_checkpoint(task: TaskRecord) -> dict[str, Any]:
|
|
172
|
+
"""The checkpoint a task's current phase reads."""
|
|
173
|
+
|
|
174
|
+
return cast(dict[str, Any], task["state"]["checkpoint"])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def call_hook(handler: Callable[..., Any], *args: Any) -> Any:
|
|
178
|
+
"""Call a hook with only the arguments it declares and await a returned awaitable."""
|
|
179
|
+
|
|
180
|
+
result = call_with_args(handler, *args)
|
|
181
|
+
if hasattr(result, "__await__"):
|
|
182
|
+
return await cast(Awaitable[Any], result)
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def request_stream_options(stream: Mapping[str, Any]) -> JsonObject:
|
|
187
|
+
"""Map Python-facing stream settings to the camelCase provider request shape."""
|
|
188
|
+
|
|
189
|
+
return cast(JsonObject, {_STREAM_OPTION_KEYS.get(key, key): value for key, value in stream.items()})
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
__all__ = [
|
|
193
|
+
"Waiters",
|
|
194
|
+
"assign_json",
|
|
195
|
+
"call_hook",
|
|
196
|
+
"call_with_args",
|
|
197
|
+
"cancellation_error",
|
|
198
|
+
"closed_error",
|
|
199
|
+
"rejected",
|
|
200
|
+
"request_stream_options",
|
|
201
|
+
"resolved",
|
|
202
|
+
"scan_all",
|
|
203
|
+
"task_checkpoint",
|
|
204
|
+
]
|