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,255 @@
|
|
|
1
|
+
"""Built-in ``pi.inbox`` and the submission boundary (spec section 6).
|
|
2
|
+
|
|
3
|
+
Queued submissions wait in one conversation-scoped document, in ID order. A boundary reads the
|
|
4
|
+
document on the Session line, places the selected writes and user items, and removes them
|
|
5
|
+
positionally; run control itself lives in ``pi.live``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Literal, TypedDict, cast
|
|
12
|
+
|
|
13
|
+
from ..documents import define_doc
|
|
14
|
+
from ..entries import UserEntry
|
|
15
|
+
from ..ids import ConversationId, EntryId, SubmissionId
|
|
16
|
+
from ..json import JsonObject, JsonValue, copy_json
|
|
17
|
+
from ..session.drafts import DocumentDraft, DraftList, DraftObject
|
|
18
|
+
from ..session.transaction import Transaction
|
|
19
|
+
from ..types import EntryDraft, EntryRecord
|
|
20
|
+
from .types import InputEntry, QueueMode, UserInput
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InboxItem(TypedDict, total=False):
|
|
24
|
+
"""One queued submission: an input with its content, or a passive entry write."""
|
|
25
|
+
|
|
26
|
+
id: SubmissionId
|
|
27
|
+
mode: Literal["steer", "followUp", "write"]
|
|
28
|
+
content: UserInput
|
|
29
|
+
entry: JsonObject
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InboxState(TypedDict):
|
|
33
|
+
"""The inbox document value: queued submissions in submission-ID order."""
|
|
34
|
+
|
|
35
|
+
items: list[InboxItem]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
InboxDoc = define_doc(
|
|
39
|
+
{
|
|
40
|
+
"kind": "pi.inbox",
|
|
41
|
+
"version": 1,
|
|
42
|
+
"scope": "conversation",
|
|
43
|
+
"history": "latest",
|
|
44
|
+
"fork": "initial",
|
|
45
|
+
"initial": lambda: {"items": []},
|
|
46
|
+
"checkpoint_when": lambda value, _ops, _info: len(value.get("items") or []) == 0,
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class QueueModes:
|
|
53
|
+
"""The queue modes a boundary reads on the Session line."""
|
|
54
|
+
|
|
55
|
+
steering_mode: QueueMode
|
|
56
|
+
follow_up_mode: QueueMode
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(slots=True)
|
|
60
|
+
class Boundary:
|
|
61
|
+
"""What a boundary reads before the commit's first table write."""
|
|
62
|
+
|
|
63
|
+
conversation_id: ConversationId
|
|
64
|
+
inbox: DocumentDraft
|
|
65
|
+
steering_mode: QueueMode
|
|
66
|
+
follow_up_mode: QueueMode
|
|
67
|
+
head: EntryId | None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class BoundaryResult:
|
|
72
|
+
"""Selected user submission IDs and whether a reset was among the placed writes."""
|
|
73
|
+
|
|
74
|
+
users: list[SubmissionId]
|
|
75
|
+
reset: bool
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _plain(value: Any) -> Any:
|
|
79
|
+
"""Return a plain JSON value from a document draft member or a record."""
|
|
80
|
+
|
|
81
|
+
if isinstance(value, DraftObject):
|
|
82
|
+
return value.snapshot()
|
|
83
|
+
if isinstance(value, DraftList):
|
|
84
|
+
return value.snapshot()
|
|
85
|
+
if isinstance(value, dict):
|
|
86
|
+
return {key: _plain(item) for key, item in value.items()}
|
|
87
|
+
if isinstance(value, list):
|
|
88
|
+
return [_plain(item) for item in value]
|
|
89
|
+
return value
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _entry_draft(item: Any) -> EntryDraft:
|
|
93
|
+
return cast(EntryDraft, _plain(item["entry"]))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def prepare_boundary(
|
|
97
|
+
tx: Transaction, conversation_id: ConversationId, modes: QueueModes
|
|
98
|
+
) -> Boundary:
|
|
99
|
+
"""Read the active-range start and the inbox before the commit's first table write."""
|
|
100
|
+
|
|
101
|
+
marker = await tx.latest_head_marker(conversation_id)
|
|
102
|
+
inbox = await tx.doc(InboxDoc, conversation_id)
|
|
103
|
+
return Boundary(
|
|
104
|
+
conversation_id=conversation_id,
|
|
105
|
+
inbox=inbox,
|
|
106
|
+
steering_mode=modes.steering_mode,
|
|
107
|
+
follow_up_mode=modes.follow_up_mode,
|
|
108
|
+
head=None if marker is None else marker.get("head"),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def apply_boundary(
|
|
113
|
+
tx: Transaction, boundary: Boundary, at: Literal["postTools", "final"], now: int
|
|
114
|
+
) -> BoundaryResult:
|
|
115
|
+
"""Place the queued items a boundary selects, writes first and then user items, each in ID order.
|
|
116
|
+
|
|
117
|
+
A selected reset turns a ``postTools`` boundary into ``final``. A head write whose target is
|
|
118
|
+
older than the active range, including one started earlier in this commit, is stale. Selected
|
|
119
|
+
and stale items are removed positionally.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
conversation_id = boundary.conversation_id
|
|
123
|
+
items = boundary.inbox["items"]
|
|
124
|
+
reset = has_queued_reset(items)
|
|
125
|
+
final = at == "final" or reset
|
|
126
|
+
|
|
127
|
+
def pick(mode: Literal["steer", "followUp"], queue_mode: QueueMode) -> list[int]:
|
|
128
|
+
indexes = [index for index, item in enumerate(items) if item["mode"] == mode]
|
|
129
|
+
return indexes if queue_mode == "all" else indexes[:1]
|
|
130
|
+
|
|
131
|
+
writes = [index for index, item in enumerate(items) if item["mode"] == "write"]
|
|
132
|
+
users = [*pick("steer", boundary.steering_mode)]
|
|
133
|
+
if final:
|
|
134
|
+
users.extend(pick("followUp", boundary.follow_up_mode))
|
|
135
|
+
users.sort()
|
|
136
|
+
|
|
137
|
+
placed: list[SubmissionId] = []
|
|
138
|
+
for index in writes:
|
|
139
|
+
item = items[index]
|
|
140
|
+
draft = _entry_draft(item)
|
|
141
|
+
if is_stale(boundary, draft):
|
|
142
|
+
tx.settle_submission(cast(SubmissionId, item["id"]), {"status": "unanswered", "reason": "stale"})
|
|
143
|
+
continue
|
|
144
|
+
entry = await tx.append_entry(conversation_id, draft)
|
|
145
|
+
head = draft.get("head")
|
|
146
|
+
if head is not None:
|
|
147
|
+
boundary.head = entry["id"] if head == "self" else head
|
|
148
|
+
tx.place_submission(cast(SubmissionId, item["id"]), entry["id"])
|
|
149
|
+
|
|
150
|
+
for index in users:
|
|
151
|
+
item = items[index]
|
|
152
|
+
entry = await append_input(
|
|
153
|
+
tx,
|
|
154
|
+
conversation_id,
|
|
155
|
+
cast(UserInput, _plain(item.get("content"))),
|
|
156
|
+
_plain(item["entry"]) if "entry" in item else None,
|
|
157
|
+
now,
|
|
158
|
+
)
|
|
159
|
+
tx.place_submission(cast(SubmissionId, item["id"]), entry["id"])
|
|
160
|
+
placed.append(cast(SubmissionId, item["id"]))
|
|
161
|
+
|
|
162
|
+
for index in sorted([*writes, *users], reverse=True):
|
|
163
|
+
del items[index]
|
|
164
|
+
return BoundaryResult(users=placed, reset=reset)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def append_input(
|
|
168
|
+
tx: Transaction,
|
|
169
|
+
conversation_id: ConversationId,
|
|
170
|
+
content: UserInput,
|
|
171
|
+
entry: InputEntry | None,
|
|
172
|
+
now: int,
|
|
173
|
+
) -> EntryRecord:
|
|
174
|
+
"""Append the entry an input is placed as: ``entry``'s kind and data, or ``pi.user``."""
|
|
175
|
+
|
|
176
|
+
message = {"role": "user", "content": content, "timestamp": now}
|
|
177
|
+
if entry is None:
|
|
178
|
+
draft = cast(EntryDraft, {"model": [message]})
|
|
179
|
+
return await tx.append_entry(UserEntry, conversation_id, draft)
|
|
180
|
+
draft = cast(EntryDraft, {**pick_input_entry(entry), "model": [message]})
|
|
181
|
+
return await tx.append_entry(conversation_id, draft)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def pick_input_entry(entry: InputEntry) -> InputEntry:
|
|
185
|
+
"""Only ``kind`` and ``data`` of an input's entry, as strict JSON."""
|
|
186
|
+
|
|
187
|
+
picked: dict[str, JsonValue] = {"kind": entry.get("kind", "")}
|
|
188
|
+
if "data" in entry:
|
|
189
|
+
picked["data"] = copy_json(entry["data"])
|
|
190
|
+
return cast(InputEntry, picked)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def is_display_only(entry: EntryDraft) -> bool:
|
|
194
|
+
"""Whether a write changes nothing the model sees: no ``model``, ``head``, or ``edits``."""
|
|
195
|
+
|
|
196
|
+
return entry.get("model") is None and entry.get("head") is None and entry.get("edits") is None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def has_queued_reset(items: Any) -> bool:
|
|
200
|
+
"""Whether a reset (``head: "self"`` write) is queued."""
|
|
201
|
+
|
|
202
|
+
return any(item["mode"] == "write" and item["entry"].get("head") == "self" for item in items)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def is_stale(boundary: Boundary, entry: EntryDraft) -> bool:
|
|
206
|
+
"""Whether a head write targets an entry before the active range."""
|
|
207
|
+
|
|
208
|
+
head = entry.get("head")
|
|
209
|
+
return (
|
|
210
|
+
isinstance(head, int)
|
|
211
|
+
and not isinstance(head, bool)
|
|
212
|
+
and boundary.head is not None
|
|
213
|
+
and head < boundary.head
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def remove_inbox_item(tx: Transaction, conversation_id: ConversationId, id: SubmissionId) -> None:
|
|
218
|
+
"""Remove a withdrawn submission's item; the caller settles the submission."""
|
|
219
|
+
|
|
220
|
+
items = (await tx.doc(InboxDoc, conversation_id))["items"]
|
|
221
|
+
for index in range(len(items)):
|
|
222
|
+
if items[index]["id"] == id:
|
|
223
|
+
del items[index]
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def withdraw_queued_inputs(tx: Transaction, conversation_id: ConversationId) -> None:
|
|
228
|
+
"""Withdraw every queued input of one conversation; queued writes stay for later placement."""
|
|
229
|
+
|
|
230
|
+
items = (await tx.doc(InboxDoc, conversation_id))["items"]
|
|
231
|
+
for index in range(len(items) - 1, -1, -1):
|
|
232
|
+
item = items[index]
|
|
233
|
+
if item["mode"] == "write":
|
|
234
|
+
continue
|
|
235
|
+
tx.settle_submission(cast(SubmissionId, item["id"]), {"status": "unanswered", "reason": "aborted"})
|
|
236
|
+
del items[index]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
__all__ = [
|
|
240
|
+
"Boundary",
|
|
241
|
+
"BoundaryResult",
|
|
242
|
+
"InboxDoc",
|
|
243
|
+
"InboxItem",
|
|
244
|
+
"InboxState",
|
|
245
|
+
"QueueModes",
|
|
246
|
+
"append_input",
|
|
247
|
+
"apply_boundary",
|
|
248
|
+
"has_queued_reset",
|
|
249
|
+
"is_display_only",
|
|
250
|
+
"is_stale",
|
|
251
|
+
"pick_input_entry",
|
|
252
|
+
"prepare_boundary",
|
|
253
|
+
"remove_inbox_item",
|
|
254
|
+
"withdraw_queued_inputs",
|
|
255
|
+
]
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Built-in ``pi.live``: run control and presentation of current work (spec section 8.2).
|
|
2
|
+
|
|
3
|
+
``run`` is present exactly while a run owns the conversation. Generation publishes its attempt,
|
|
4
|
+
partial, retry, and deferred state here; a tool call publishes throttled running output into its
|
|
5
|
+
slot, model-issued or nested. A compaction task lists its status here while it selects, summarizes,
|
|
6
|
+
or waits out a retry backoff, and the newest failure stays for a client to show.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from typing import Any, NotRequired, TypedDict, cast
|
|
13
|
+
|
|
14
|
+
from ..documents import define_doc
|
|
15
|
+
from ..errors import error_message
|
|
16
|
+
from ..ids import ConversationId, EntryId, SubmissionId, TaskId
|
|
17
|
+
from ..json import JsonValue
|
|
18
|
+
from ..session.drafts import DocumentDraft
|
|
19
|
+
from ..session.transaction import Transaction
|
|
20
|
+
from ..types import SubmissionSettlement, TaskRecord
|
|
21
|
+
from .types import CompactionReason, ToolDiagnostic
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RunState(TypedDict):
|
|
25
|
+
"""The task that settles the run's inputs and those inputs; present exactly while busy."""
|
|
26
|
+
|
|
27
|
+
taskId: TaskId
|
|
28
|
+
inputs: list[SubmissionId]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SlotProgress(TypedDict, total=False):
|
|
32
|
+
"""What a running tool call publishes, shared by model-issued and nested calls."""
|
|
33
|
+
|
|
34
|
+
callId: str
|
|
35
|
+
name: str
|
|
36
|
+
status: str
|
|
37
|
+
output: str
|
|
38
|
+
droppedBytes: int
|
|
39
|
+
droppedLines: int
|
|
40
|
+
details: JsonValue
|
|
41
|
+
diagnostics: list[ToolDiagnostic]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ToolSlot(SlotProgress):
|
|
45
|
+
"""Presentation of one tool call of the current round.
|
|
46
|
+
|
|
47
|
+
``taskId`` is absent for a call not started yet (a sequential round) and for a call its request
|
|
48
|
+
did not offer, which starts ``done`` with the ``entry`` generation wrote. ``entry`` is absent
|
|
49
|
+
when the tool task faulted or was orphaned.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
taskId: NotRequired[TaskId]
|
|
53
|
+
entry: NotRequired[EntryId]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class NestedToolSummary(TypedDict, total=False):
|
|
57
|
+
"""How a nested call ended: an error flag, its execution time, spend, and bounded error text."""
|
|
58
|
+
|
|
59
|
+
isError: bool
|
|
60
|
+
durationMs: int
|
|
61
|
+
usage: dict[str, JsonValue]
|
|
62
|
+
error: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class NestedToolSlot(SlotProgress):
|
|
66
|
+
"""Presentation of one nested call: a call a running tool made through ``executeTool()``."""
|
|
67
|
+
|
|
68
|
+
parentCallId: str
|
|
69
|
+
parentTaskId: TaskId
|
|
70
|
+
taskId: TaskId
|
|
71
|
+
arguments: dict[str, JsonValue]
|
|
72
|
+
summary: NotRequired[NestedToolSummary]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class CompactionStatus(TypedDict, total=False):
|
|
76
|
+
"""Presentation of one live compaction task (spec section 8.7)."""
|
|
77
|
+
|
|
78
|
+
taskId: TaskId
|
|
79
|
+
reason: CompactionReason
|
|
80
|
+
blocking: bool
|
|
81
|
+
attempt: int
|
|
82
|
+
retry: dict[str, JsonValue]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CompactionFailure(TypedDict):
|
|
86
|
+
"""The newest compaction that failed since a compaction last produced a summary."""
|
|
87
|
+
|
|
88
|
+
taskId: TaskId
|
|
89
|
+
reason: CompactionReason
|
|
90
|
+
error: str
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class LiveState(TypedDict, total=False):
|
|
94
|
+
"""Built-in live conversation state."""
|
|
95
|
+
|
|
96
|
+
run: RunState
|
|
97
|
+
generation: dict[str, JsonValue]
|
|
98
|
+
tools: list[ToolSlot]
|
|
99
|
+
nestedTools: list[NestedToolSlot]
|
|
100
|
+
compactions: list[CompactionStatus]
|
|
101
|
+
compactionFailure: CompactionFailure
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _checkpoint_when(value: dict[str, Any], _ops: Any, _info: Any) -> bool:
|
|
105
|
+
"""A complete base whenever nothing runs: no generation and no running tool slots."""
|
|
106
|
+
|
|
107
|
+
if value.get("generation") is not None:
|
|
108
|
+
return False
|
|
109
|
+
for key in ("tools", "nestedTools"):
|
|
110
|
+
slots = value.get(key) or []
|
|
111
|
+
if any(slot.get("status") == "running" for slot in slots):
|
|
112
|
+
return False
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
LiveDoc = define_doc(
|
|
117
|
+
{
|
|
118
|
+
"kind": "pi.live",
|
|
119
|
+
"version": 1,
|
|
120
|
+
"scope": "conversation",
|
|
121
|
+
"history": "latest",
|
|
122
|
+
"fork": "initial",
|
|
123
|
+
"initial": lambda: {},
|
|
124
|
+
"checkpoint_when": _checkpoint_when,
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
GENERATION_TASK_NAME = "pi.generation"
|
|
129
|
+
TOOL_TASK_KIND = "pi.tool"
|
|
130
|
+
COMPACTION_TASK_KIND = "pi.compaction"
|
|
131
|
+
RUN_TASK_KINDS = frozenset({GENERATION_TASK_NAME})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def start_generation(
|
|
135
|
+
tx: Transaction,
|
|
136
|
+
conversation_id: ConversationId,
|
|
137
|
+
live: Any,
|
|
138
|
+
inputs: list[SubmissionId],
|
|
139
|
+
registry: Any,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Create the registry's generation task and give it run control of ``inputs``."""
|
|
142
|
+
|
|
143
|
+
task = registry.task(GENERATION_TASK_NAME)
|
|
144
|
+
if task is None:
|
|
145
|
+
raise RuntimeError("Registry lacks the pi.generation task")
|
|
146
|
+
await start_run(tx, conversation_id, live, inputs, task)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def start_run(
|
|
150
|
+
tx: Transaction,
|
|
151
|
+
conversation_id: ConversationId,
|
|
152
|
+
live: DocumentDraft,
|
|
153
|
+
inputs: list[SubmissionId],
|
|
154
|
+
task: Any,
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Create the generation task and give it run control of ``inputs``."""
|
|
157
|
+
|
|
158
|
+
task_id = await tx.create_task(
|
|
159
|
+
task,
|
|
160
|
+
{},
|
|
161
|
+
{"ownership": {"kind": "conversation"}, "conversationId": conversation_id},
|
|
162
|
+
)
|
|
163
|
+
live["run"] = {"taskId": task_id, "inputs": list(inputs)}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def end_run(
|
|
167
|
+
tx: Transaction,
|
|
168
|
+
live: DocumentDraft,
|
|
169
|
+
task_id: TaskId,
|
|
170
|
+
settlement: SubmissionSettlement,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""End the run owned by ``task_id``: settle its inputs and remove run control."""
|
|
173
|
+
|
|
174
|
+
run = live.get("run")
|
|
175
|
+
if run is not None and run["taskId"] == task_id:
|
|
176
|
+
for id in run["inputs"]:
|
|
177
|
+
tx.settle_submission(cast(SubmissionId, id), settlement)
|
|
178
|
+
del live["run"]
|
|
179
|
+
for key in ("generation", "tools", "nestedTools"):
|
|
180
|
+
if key in live:
|
|
181
|
+
del live[key]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def add_compaction_status(live: Any, status: Mapping[str, Any]) -> None:
|
|
185
|
+
"""Add the status of a compaction task created in this commit; statuses stay in task ID order."""
|
|
186
|
+
|
|
187
|
+
statuses = live.get("compactions")
|
|
188
|
+
if statuses is None:
|
|
189
|
+
live["compactions"] = [dict(status)]
|
|
190
|
+
else:
|
|
191
|
+
statuses.append(dict(status))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def compaction_status(live: Any, task_id: TaskId) -> Any:
|
|
195
|
+
"""The status draft of compaction task ``task_id``, if listed."""
|
|
196
|
+
|
|
197
|
+
statuses = live.get("compactions")
|
|
198
|
+
if statuses is None:
|
|
199
|
+
return None
|
|
200
|
+
return next((status for status in statuses if status.get("taskId") == task_id), None)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def remove_compaction_status(live: Any, task_id: TaskId) -> None:
|
|
204
|
+
"""Remove the status of compaction task ``task_id``, and the list once empty."""
|
|
205
|
+
|
|
206
|
+
statuses = live.get("compactions")
|
|
207
|
+
if statuses is None:
|
|
208
|
+
return
|
|
209
|
+
for index in range(len(statuses)):
|
|
210
|
+
if statuses[index].get("taskId") == task_id:
|
|
211
|
+
del statuses[index]
|
|
212
|
+
break
|
|
213
|
+
if len(statuses) == 0:
|
|
214
|
+
del live["compactions"]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def clear_compaction_failure(live: Any) -> None:
|
|
218
|
+
"""Remove the recorded compaction failure, once a compaction produces a summary."""
|
|
219
|
+
|
|
220
|
+
if "compactionFailure" in live:
|
|
221
|
+
del live["compactionFailure"]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def fail_compaction_status(live: Any, task_id: TaskId, error: str) -> None:
|
|
225
|
+
"""Remove the status of compaction task ``task_id`` and record that it failed with ``error``."""
|
|
226
|
+
|
|
227
|
+
status = compaction_status(live, task_id)
|
|
228
|
+
if status is not None:
|
|
229
|
+
live["compactionFailure"] = {
|
|
230
|
+
"taskId": status["taskId"],
|
|
231
|
+
"reason": status["reason"],
|
|
232
|
+
"error": error,
|
|
233
|
+
}
|
|
234
|
+
remove_compaction_status(live, task_id)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def tool_slot(live: Any, task_id: TaskId) -> Any:
|
|
238
|
+
"""The slot of tool task ``task_id``, model-issued or nested, if ``pi.live`` still lists it.
|
|
239
|
+
|
|
240
|
+
Nested slots are found by binary search: a tool may make many nested calls, each of which looks
|
|
241
|
+
up its slot in several commits, and their task IDs ascend.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
slots = live.get("tools") if hasattr(live, "get") else None
|
|
245
|
+
for slot in slots or ():
|
|
246
|
+
if slot.get("taskId") == task_id:
|
|
247
|
+
return slot
|
|
248
|
+
nested = live.get("nestedTools") if hasattr(live, "get") else None
|
|
249
|
+
if nested is None:
|
|
250
|
+
return None
|
|
251
|
+
low = 0
|
|
252
|
+
high = len(nested) - 1
|
|
253
|
+
while low <= high:
|
|
254
|
+
middle = (low + high) >> 1
|
|
255
|
+
candidate = nested[middle]
|
|
256
|
+
candidate_id = candidate.get("taskId")
|
|
257
|
+
if candidate_id == task_id:
|
|
258
|
+
return candidate
|
|
259
|
+
if candidate_id is not None and candidate_id < task_id:
|
|
260
|
+
low = middle + 1
|
|
261
|
+
else:
|
|
262
|
+
high = middle - 1
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def finish_slot(slot: Any) -> None:
|
|
267
|
+
"""Mark a slot done and clear its running output; the caller sets ``entry`` or ``summary``."""
|
|
268
|
+
|
|
269
|
+
slot["status"] = "done"
|
|
270
|
+
clear_progress(slot)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def remove_nested_slots(live: Any, task_id: TaskId) -> None:
|
|
274
|
+
"""Remove the nested slots below tool task ``task_id``, transitively; parents precede children."""
|
|
275
|
+
|
|
276
|
+
nested = live.get("nestedTools") if hasattr(live, "get") else None
|
|
277
|
+
if nested is None:
|
|
278
|
+
return
|
|
279
|
+
removed = {task_id}
|
|
280
|
+
for slot in nested:
|
|
281
|
+
if slot.get("parentTaskId") in removed:
|
|
282
|
+
removed.add(slot.get("taskId"))
|
|
283
|
+
for index in range(len(nested) - 1, -1, -1):
|
|
284
|
+
if nested[index].get("parentTaskId") in removed:
|
|
285
|
+
del nested[index]
|
|
286
|
+
if len(nested) == 0:
|
|
287
|
+
del live["nestedTools"]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def clear_progress(slot: Any) -> None:
|
|
291
|
+
"""Remove what a tool published while running; its result entry or a rerun replaces it."""
|
|
292
|
+
|
|
293
|
+
for key in ("output", "droppedBytes", "droppedLines", "details", "diagnostics"):
|
|
294
|
+
if key in slot:
|
|
295
|
+
del slot[key]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def settle_scheduler_outcome(tx: Transaction, record: TaskRecord, outcome: dict[str, Any]) -> None:
|
|
299
|
+
"""Harness cleanup for a terminal outcome the scheduler writes itself (faulted or orphaned)."""
|
|
300
|
+
|
|
301
|
+
if record["kind"] == TOOL_TASK_KIND:
|
|
302
|
+
live = await tx.doc(LiveDoc, record["conversationId"])
|
|
303
|
+
slot = tool_slot(live, record["id"])
|
|
304
|
+
if slot is not None:
|
|
305
|
+
finish_slot(slot)
|
|
306
|
+
remove_nested_slots(live, record["id"])
|
|
307
|
+
return
|
|
308
|
+
if record["kind"] == COMPACTION_TASK_KIND:
|
|
309
|
+
failure_text = (
|
|
310
|
+
str(cast(Mapping[str, Any], outcome.get("error") or {}).get("message", ""))
|
|
311
|
+
if outcome.get("status") == "faulted"
|
|
312
|
+
else str(outcome.get("reason", ""))
|
|
313
|
+
)
|
|
314
|
+
fail_compaction_status(
|
|
315
|
+
await tx.doc(LiveDoc, record["conversationId"]), record["id"], failure_text
|
|
316
|
+
)
|
|
317
|
+
return
|
|
318
|
+
if record["kind"] not in RUN_TASK_KINDS:
|
|
319
|
+
return
|
|
320
|
+
live = await tx.doc(LiveDoc, record["conversationId"])
|
|
321
|
+
run = live.get("run")
|
|
322
|
+
if run is None or run["taskId"] != record["id"]:
|
|
323
|
+
return
|
|
324
|
+
from .generation import convert_partial
|
|
325
|
+
|
|
326
|
+
await convert_partial(tx, live, record["conversationId"])
|
|
327
|
+
if outcome.get("status") == "faulted":
|
|
328
|
+
error = outcome.get("error") or {}
|
|
329
|
+
end_run(
|
|
330
|
+
tx,
|
|
331
|
+
live,
|
|
332
|
+
record["id"],
|
|
333
|
+
{
|
|
334
|
+
"status": "unanswered",
|
|
335
|
+
"reason": "faulted",
|
|
336
|
+
"detail": error.get("message", error_message(error)),
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
return
|
|
340
|
+
end_run(
|
|
341
|
+
tx,
|
|
342
|
+
live,
|
|
343
|
+
record["id"],
|
|
344
|
+
{"status": "unanswered", "reason": outcome.get("reason", "aborted")},
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
__all__ = [
|
|
349
|
+
"COMPACTION_TASK_KIND",
|
|
350
|
+
"GENERATION_TASK_NAME",
|
|
351
|
+
"CompactionFailure",
|
|
352
|
+
"CompactionStatus",
|
|
353
|
+
"LiveDoc",
|
|
354
|
+
"LiveState",
|
|
355
|
+
"NestedToolSlot",
|
|
356
|
+
"NestedToolSummary",
|
|
357
|
+
"RUN_TASK_KINDS",
|
|
358
|
+
"RunState",
|
|
359
|
+
"TOOL_TASK_KIND",
|
|
360
|
+
"ToolSlot",
|
|
361
|
+
"add_compaction_status",
|
|
362
|
+
"clear_compaction_failure",
|
|
363
|
+
"clear_progress",
|
|
364
|
+
"compaction_status",
|
|
365
|
+
"end_run",
|
|
366
|
+
"fail_compaction_status",
|
|
367
|
+
"finish_slot",
|
|
368
|
+
"remove_compaction_status",
|
|
369
|
+
"remove_nested_slots",
|
|
370
|
+
"settle_scheduler_outcome",
|
|
371
|
+
"start_generation",
|
|
372
|
+
"start_run",
|
|
373
|
+
"tool_slot",
|
|
374
|
+
]
|