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,288 @@
1
+ """System prompt replay, rendering, and positional ``pi.system`` planning.
2
+
3
+ Port of v1.3.0 ``src/harness/prompt.ts``. Durable stores no prompt sections of its own: the
4
+ conversation's resolved agent renders the desired sections for each request, and every change is
5
+ written as a positional ``pi.system`` entry whose model message is a pi-ai ``SystemMessage``.
6
+ Replaying those messages in transcript order yields the sections and tools the model has seen.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
13
+ from typing import Any, Never, NotRequired, TypedDict, cast
14
+
15
+ from ..context import Context
16
+ from ..entries import SystemEntry
17
+ from ..json import JsonValue, copy_json
18
+ from ..providers.transcript import current_tools as replay_tools
19
+ from ..types import ContextEdit, MessageRecord, TypedEntryDraft
20
+ from .types import ContextView, PromptInput, PromptSection, ToolRegistration
21
+
22
+
23
+ class ToolDeclaration(TypedDict):
24
+ """The provider-facing part of a tool, exactly as persisted in a system message."""
25
+
26
+ name: str
27
+ description: str
28
+ parameters: JsonValue
29
+ constrainedSampling: NotRequired[JsonValue]
30
+
31
+
32
+ class ToolChanges(TypedDict):
33
+ """Tool removals and additions planned for one positional message."""
34
+
35
+ toolsRemoved: list[dict[str, str]]
36
+ toolsAdded: list[ToolDeclaration]
37
+
38
+
39
+ SystemDraft = TypedEntryDraft[Never]
40
+
41
+
42
+ def replay_sections(messages: Sequence[Mapping[str, Any]]) -> dict[str, str]:
43
+ """Replay system messages into the sections in effect.
44
+
45
+ A section sets or replaces its name in place without moving it, ``null`` removes it, and a
46
+ later re-addition appends it to the ordered map. Instruction ``content`` is not a section:
47
+ durable always writes an empty ``content``, and the messages keep it for provider replay.
48
+ """
49
+
50
+ shown: dict[str, str] = {}
51
+ for message in messages:
52
+ if message.get("role") != "system":
53
+ continue
54
+ sections = message.get("sections")
55
+ if sections is None:
56
+ continue
57
+ for key, value in cast(Mapping[str, str | None], sections).items():
58
+ if value is None:
59
+ shown.pop(key, None)
60
+ else:
61
+ shown[key] = value
62
+ return shown
63
+
64
+
65
+ async def render_sections(
66
+ sections: Sequence[PromptSection],
67
+ input: PromptInput,
68
+ shown: Mapping[str, str],
69
+ report: Callable[[BaseException], None],
70
+ context: Context,
71
+ ) -> dict[str, str]:
72
+ """Render the agent's sections in order.
73
+
74
+ A renderer returning ``None`` omits its section. Tagged text is wrapped as
75
+ ``<key>\\n...\\n</key>``. A section that raises keeps its shown text, if any, and is reported;
76
+ errors after the invocation was cancelled propagate.
77
+ """
78
+
79
+ desired: dict[str, str] = {}
80
+ for section in sections:
81
+ try:
82
+ rendered = section.render(input, context)
83
+ if hasattr(rendered, "__await__"):
84
+ rendered = await cast(Awaitable[Any], rendered)
85
+ except Exception as error:
86
+ signal = context.abort_signal
87
+ if signal is not None and signal.aborted:
88
+ raise
89
+ report(error)
90
+ kept = shown.get(section.key)
91
+ if kept is not None:
92
+ desired[section.key] = kept
93
+ continue
94
+ if rendered is None:
95
+ continue
96
+ text = cast(str, rendered)
97
+ desired[section.key] = (
98
+ text if section.tag is False else f"<{section.key}>\n{text}\n</{section.key}>"
99
+ )
100
+ return desired
101
+
102
+
103
+ def to_tool_declaration(
104
+ tool: ToolRegistration | Mapping[str, Any],
105
+ ) -> ToolDeclaration:
106
+ """Strip execution and display-only fields, leaving the declaration the model sees."""
107
+
108
+ if isinstance(tool, Mapping):
109
+ name = cast(str, tool["name"])
110
+ description = cast(str, tool.get("description") or "")
111
+ parameters = tool.get("parameters")
112
+ sampling = tool.get("constrainedSampling")
113
+ else:
114
+ name = tool.name
115
+ description = tool.description
116
+ parameters = tool.parameters
117
+ sampling = tool.constrained_sampling
118
+ declaration: dict[str, Any] = {
119
+ "name": name,
120
+ "description": description,
121
+ "parameters": copy_json(parameters),
122
+ }
123
+ if sampling is not None:
124
+ declaration["constrainedSampling"] = copy_json(sampling)
125
+ return cast(ToolDeclaration, declaration)
126
+
127
+
128
+ def declarations_equal(
129
+ left: ToolRegistration | Mapping[str, Any],
130
+ right: ToolRegistration | Mapping[str, Any],
131
+ ) -> bool:
132
+ """Whether two tools declare the same interface to the model."""
133
+
134
+ return _serialized(left) == _serialized(right)
135
+
136
+
137
+ def _serialized(tool: ToolRegistration | Mapping[str, Any]) -> str:
138
+ return json.dumps(to_tool_declaration(tool), ensure_ascii=False, separators=(",", ":"))
139
+
140
+
141
+ def current_tools(messages: Sequence[Mapping[str, Any]]) -> list[ToolDeclaration]:
142
+ """Resolve the tools available after applying every transcript delta in order.
143
+
144
+ The replay itself lives with the provider adapters, which read the same transcript.
145
+ """
146
+
147
+ return [cast(ToolDeclaration, tool) for tool in replay_tools(messages)]
148
+
149
+
150
+ def plan_system_entries(
151
+ view: ContextView,
152
+ desired: Mapping[str, str],
153
+ tools: Sequence[ToolRegistration | Mapping[str, Any]],
154
+ timestamp: int,
155
+ ) -> list[SystemDraft]:
156
+ """Plan the ``pi.system`` entries that make ``view`` equal ``desired`` in values and order.
157
+
158
+ - A head marker with no later ``pi.system`` entry in context: one complete baseline that
159
+ omits every retained earlier ``pi.system`` entry, written even when it restates replay.
160
+ - Otherwise, when a minimal section patch would leave a different order: remove every shown
161
+ section, then re-add every desired section in order.
162
+ - Otherwise the minimal patch of changed values and ``null`` removals, or nothing.
163
+
164
+ Tool changes ride on the last planned entry, or on one entry of their own.
165
+ """
166
+
167
+ head = view.get("head")
168
+ if head is not None and not any(
169
+ SystemEntry.is_(entry) and entry["id"] > head["id"] for entry in view["entries"]
170
+ ):
171
+ edits = [
172
+ cast(ContextEdit, {"target": entry["id"], "action": "omit"})
173
+ for entry in view["entries"]
174
+ if SystemEntry.is_(entry)
175
+ ]
176
+ baseline = _system_entry(
177
+ dict(desired),
178
+ {
179
+ "toolsRemoved": [],
180
+ "toolsAdded": [to_tool_declaration(tool) for tool in tools],
181
+ },
182
+ timestamp,
183
+ )
184
+ if edits:
185
+ baseline["edits"] = edits
186
+ return [baseline]
187
+
188
+ patches = _plan_sections(replay_sections(view["messages"]), desired)
189
+ changes = _plan_tools(current_tools(view["messages"]), tools)
190
+ if not changes["toolsRemoved"] and not changes["toolsAdded"]:
191
+ return [_system_entry(patch, None, timestamp) for patch in patches]
192
+ if not patches:
193
+ return [_system_entry(None, changes, timestamp)]
194
+ return [
195
+ _system_entry(patch, changes if index == len(patches) - 1 else None, timestamp)
196
+ for index, patch in enumerate(patches)
197
+ ]
198
+
199
+
200
+ def _plan_sections(
201
+ shown: Mapping[str, str],
202
+ desired: Mapping[str, str],
203
+ ) -> list[dict[str, str | None]]:
204
+ patched_order = [
205
+ *[key for key in shown if key in desired],
206
+ *[key for key in desired if key not in shown],
207
+ ]
208
+ if patched_order != list(desired):
209
+ return [
210
+ {key: None for key in shown},
211
+ dict(desired),
212
+ ]
213
+
214
+ patch: dict[str, str | None] = {}
215
+ for key, value in shown.items():
216
+ next_value = desired.get(key)
217
+ if next_value != value:
218
+ patch[key] = next_value
219
+ for key, value in desired.items():
220
+ if key not in shown:
221
+ patch[key] = value
222
+ return [] if not patch else [patch]
223
+
224
+
225
+ def _plan_tools(
226
+ offered: Sequence[ToolDeclaration],
227
+ desired: Sequence[ToolRegistration | Mapping[str, Any]],
228
+ ) -> ToolChanges:
229
+ declared = [to_tool_declaration(tool) for tool in desired]
230
+ wanted = {tool["name"]: tool for tool in declared}
231
+ kept = [
232
+ tool
233
+ for tool in offered
234
+ if (next_tool := wanted.get(tool["name"])) is not None
235
+ and declarations_equal(tool, next_tool)
236
+ ]
237
+ kept_names = {tool["name"] for tool in kept}
238
+ added = [tool for tool in declared if tool["name"] not in kept_names]
239
+ replayed = [*kept, *added]
240
+ if len(replayed) != len(declared) or any(
241
+ tool["name"] != declared[index]["name"] for index, tool in enumerate(replayed)
242
+ ):
243
+ return {
244
+ "toolsRemoved": [{"name": tool["name"]} for tool in offered],
245
+ "toolsAdded": declared,
246
+ }
247
+ return {
248
+ "toolsRemoved": [
249
+ {"name": tool["name"]} for tool in offered if tool["name"] not in kept_names
250
+ ],
251
+ "toolsAdded": added,
252
+ }
253
+
254
+
255
+ def _system_entry(
256
+ sections: Mapping[str, str | None] | None,
257
+ tools: ToolChanges | None,
258
+ timestamp: int,
259
+ ) -> SystemDraft:
260
+ message: dict[str, Any] = {"role": "system", "content": ""}
261
+ if sections is not None:
262
+ message["sections"] = dict(sections)
263
+ if tools is not None:
264
+ if tools["toolsRemoved"]:
265
+ message["toolsRemoved"] = tools["toolsRemoved"]
266
+ if tools["toolsAdded"]:
267
+ message["toolsAdded"] = tools["toolsAdded"]
268
+ message["timestamp"] = timestamp
269
+ return cast(
270
+ SystemDraft,
271
+ {
272
+ "kind": SystemEntry.kind,
273
+ "model": cast(list[MessageRecord], [message]),
274
+ },
275
+ )
276
+
277
+
278
+ __all__ = [
279
+ "SystemDraft",
280
+ "ToolChanges",
281
+ "ToolDeclaration",
282
+ "current_tools",
283
+ "declarations_equal",
284
+ "plan_system_entries",
285
+ "render_sections",
286
+ "replay_sections",
287
+ "to_tool_declaration",
288
+ ]
@@ -0,0 +1,78 @@
1
+ """Built-in ``pi.provider``: the stable provider-facing identity of one conversation.
2
+
3
+ Port of v1.3.0 ``src/harness/provider.ts``. Generation and compaction pass the persisted UUID to
4
+ the provider as ``sessionId``; a fork starts with a fresh identity instead of copying its parent's.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import time
11
+ import uuid
12
+ from typing import Any, TypedDict
13
+
14
+ from ..context import Context
15
+ from ..documents import define_doc
16
+ from ..session.transaction import Transaction
17
+
18
+
19
+ class ProviderState(TypedDict):
20
+ """Stable provider-facing identity of one conversation."""
21
+
22
+ sessionId: str
23
+
24
+
25
+ def uuidv7() -> str:
26
+ """A UUIDv7 (time-ordered) string, as pi-ai's ``uuidv7()``."""
27
+
28
+ milliseconds = int(time.time() * 1000) & ((1 << 48) - 1)
29
+ value = bytearray(os.urandom(16))
30
+ value[0:6] = milliseconds.to_bytes(6, "big")
31
+ value[6] = (value[6] & 0x0F) | 0x70
32
+ value[8] = (value[8] & 0x3F) | 0x80
33
+ return str(uuid.UUID(bytes=bytes(value)))
34
+
35
+
36
+ ProviderDoc = define_doc(
37
+ {
38
+ "kind": "pi.provider",
39
+ "version": 1,
40
+ "scope": "conversation",
41
+ "history": "latest",
42
+ "fork": "initial",
43
+ "initial": lambda: {"sessionId": uuidv7()},
44
+ "checkpoint_when": lambda _state, _ops, _info: True,
45
+ }
46
+ )
47
+
48
+
49
+ async def ensure_provider_session_id(runtime: Any, context: Context) -> str:
50
+ """Return the persisted identity without writing in the normal path.
51
+
52
+ A legacy conversation without ``pi.provider`` gets one migration commit whose ``tx.doc()`` runs
53
+ ``initial()`` before the provider request starts.
54
+ """
55
+
56
+ existing = await runtime.snapshot(ProviderDoc, runtime.conversation_id, context)
57
+ if existing is not None:
58
+ return _session_id(existing)
59
+
60
+ created: dict[str, str] = {}
61
+
62
+ async def create(tx: Transaction) -> None:
63
+ created["session"] = _session_id(await tx.doc(ProviderDoc, runtime.conversation_id))
64
+
65
+ await runtime.commit(create, context)
66
+ if "session" not in created:
67
+ raise RuntimeError(f"Conversation {runtime.conversation_id} has no provider session ID")
68
+ return created["session"]
69
+
70
+
71
+ def _session_id(state: Any) -> str:
72
+ value = state["sessionId"]
73
+ if not isinstance(value, str) or not value:
74
+ raise TypeError("pi.provider is missing its sessionId")
75
+ return value
76
+
77
+
78
+ __all__ = ["ProviderDoc", "ProviderState", "ensure_provider_session_id", "uuidv7"]
@@ -0,0 +1,136 @@
1
+ """Application-owned extension registry and immutable published snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from .agent import INSTRUCTIONS_KEY
10
+ from .compaction import CompactionTask
11
+ from .generation import GenerationTask
12
+ from .tool import ToolTask
13
+ from .types import (
14
+ AnyTask,
15
+ Extension,
16
+ Registry,
17
+ RegistrySnapshot,
18
+ )
19
+
20
+ _SECTION_KEY = re.compile(r"^[a-z][a-z0-9_-]*$")
21
+
22
+ #: Built-in task definitions every registry holds.
23
+ BUILTIN_TASKS: tuple[AnyTask, ...] = (GenerationTask, ToolTask, CompactionTask)
24
+
25
+
26
+ class _RegistryState:
27
+ """Immutable published registry state."""
28
+
29
+ __slots__ = ("_by_name", "_extensions", "_tasks")
30
+
31
+ def __init__(self, extensions: tuple[Extension, ...]) -> None:
32
+ self._extensions = extensions
33
+ self._by_name = {extension.name: extension for extension in extensions}
34
+ tasks: dict[str, AnyTask] = {}
35
+ for task in BUILTIN_TASKS:
36
+ tasks[task.definition.name] = task
37
+ for extension in extensions:
38
+ for task in extension.tasks:
39
+ name = task.definition.name
40
+ if name in tasks:
41
+ raise ValueError(f"Task {name} of extension {extension.name} is already installed")
42
+ tasks[name] = task
43
+ self._tasks = tasks
44
+
45
+ def installed(self) -> tuple[Extension, ...]:
46
+ return self._extensions
47
+
48
+ def extension(self, name: str) -> Extension | None:
49
+ return self._by_name.get(name)
50
+
51
+ def tools(self) -> list[dict[str, Any]]:
52
+ return [
53
+ {"extension": extension, "tool": tool}
54
+ for extension in self._extensions
55
+ for tool in extension.tools
56
+ ]
57
+
58
+ def sections(self) -> list[dict[str, Any]]:
59
+ return [
60
+ {"extension": extension, "section": section}
61
+ for extension in self._extensions
62
+ for section in extension.sections
63
+ ]
64
+
65
+ def tasks(self) -> list[AnyTask]:
66
+ return list(self._tasks.values())
67
+
68
+ def task(self, name: str) -> AnyTask | None:
69
+ return self._tasks.get(name)
70
+
71
+
72
+ class _Registry:
73
+ """Application-owned registry of extensions."""
74
+
75
+ def __init__(self) -> None:
76
+ self._current = _RegistryState(())
77
+ self._listeners: set[Callable[[], Any]] = set()
78
+
79
+ def snapshot(self) -> RegistrySnapshot:
80
+ return self._current
81
+
82
+ def subscribe(self, listener: Callable[[], Any]) -> Callable[[], None]:
83
+ self._listeners.add(listener)
84
+ return lambda: self._listeners.discard(listener)
85
+
86
+ def install(self, extension: Extension) -> None:
87
+ _validate_extension(extension)
88
+ current = self._current.installed()
89
+ index = next((i for i, installed in enumerate(current) if installed.name == extension.name), -1)
90
+ if index < 0:
91
+ following = (*current, extension)
92
+ else:
93
+ following = (*current[:index], extension, *current[index + 1 :])
94
+ self._publish(following)
95
+
96
+ def uninstall(self, extension: Extension) -> None:
97
+ current = self._current.installed()
98
+ if not any(installed.name == extension.name for installed in current):
99
+ return
100
+ self._publish(tuple(installed for installed in current if installed.name != extension.name))
101
+
102
+ def _publish(self, extensions: tuple[Extension, ...]) -> None:
103
+ # Build and validate the next state first, so a collision publishes nothing.
104
+ self._current = _RegistryState(extensions)
105
+ for listener in list(self._listeners):
106
+ listener()
107
+
108
+
109
+ def _validate_extension(extension: Extension) -> None:
110
+ tools: set[str] = set()
111
+ for tool in extension.tools:
112
+ if tool.name in tools:
113
+ raise ValueError(f"Extension {extension.name} has two tools named {tool.name}")
114
+ tools.add(tool.name)
115
+ sections: set[str] = set()
116
+ for section in extension.sections:
117
+ if not _SECTION_KEY.match(section.key):
118
+ raise TypeError(f"Section key {section.key!r} must match {_SECTION_KEY.pattern}")
119
+ if section.key == INSTRUCTIONS_KEY:
120
+ raise ValueError(f"Section key {section.key} is reserved for the agent's instructions")
121
+ if section.key in sections:
122
+ raise ValueError(f"Extension {extension.name} has two sections with key {section.key}")
123
+ sections.add(section.key)
124
+
125
+
126
+ def create_registry() -> Registry:
127
+ """Create an application-owned registry holding only the built-in tasks."""
128
+
129
+ return _Registry()
130
+
131
+
132
+ __all__ = [
133
+ "BUILTIN_TASKS",
134
+ "INSTRUCTIONS_KEY",
135
+ "create_registry",
136
+ ]