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,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
|
+
]
|