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,515 @@
|
|
|
1
|
+
"""Context derivation: the active transcript and its model-facing context.
|
|
2
|
+
|
|
3
|
+
Port of v1.3.0 ``src/harness/context.ts``. A read captures two bounds on the Session line — the
|
|
4
|
+
newest head marker at or below the cutoff and the cutoff entry — and then derives the view from
|
|
5
|
+
the immutable entries at or below the cutoff off that line.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
11
|
+
from typing import Any, NotRequired, Protocol, TypedDict, cast
|
|
12
|
+
|
|
13
|
+
from ..context import Context
|
|
14
|
+
from ..entries import SystemEntry
|
|
15
|
+
from ..ids import ConversationId, EntryId
|
|
16
|
+
from ..json import freeze_json
|
|
17
|
+
from ..types import (
|
|
18
|
+
ContextEdit,
|
|
19
|
+
Cursor,
|
|
20
|
+
EntryQuery,
|
|
21
|
+
EntryRecord,
|
|
22
|
+
EntryWithHead,
|
|
23
|
+
MessageRecord,
|
|
24
|
+
Storage,
|
|
25
|
+
)
|
|
26
|
+
from .types import ContextView
|
|
27
|
+
|
|
28
|
+
#: Largest page one range scan requests, matching the other built-in scans.
|
|
29
|
+
_SCAN_PAGE_SIZE = 256
|
|
30
|
+
|
|
31
|
+
#: Stop reasons whose assistant message never reaches a later provider request.
|
|
32
|
+
EXCLUDED_STOP_REASONS = frozenset({"aborted", "error", "deferred"})
|
|
33
|
+
|
|
34
|
+
#: Text of the result synthesized for a call the visible history cut off.
|
|
35
|
+
MISSING_RESULT_TEXT = "Tool result unavailable: history ends before this call completed."
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ContextBounds(TypedDict):
|
|
39
|
+
"""Head marker and newest visible entry that fix one committed context range."""
|
|
40
|
+
|
|
41
|
+
head: EntryWithHead | None
|
|
42
|
+
tail: EntryId
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EntryRange(TypedDict):
|
|
46
|
+
"""The visible range within captured bounds, without the conversation it belongs to."""
|
|
47
|
+
|
|
48
|
+
minEntryId: NotRequired[EntryId]
|
|
49
|
+
maxEntryId: EntryId
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _Undecided:
|
|
53
|
+
"""Marker for a range whose entries so far leave the baseline open."""
|
|
54
|
+
|
|
55
|
+
def __repr__(self) -> str:
|
|
56
|
+
return "UNDECIDED"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
UNDECIDED = _Undecided()
|
|
60
|
+
"""No entry so far supplies a baseline, and a later entry still may."""
|
|
61
|
+
|
|
62
|
+
Baseline = MessageRecord | None | _Undecided
|
|
63
|
+
"""The baseline's system message, ``None`` when the range cannot have one, or ``UNDECIDED``."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ContextRange(TypedDict):
|
|
67
|
+
"""A derived context range, reusable as the previous range of a later read."""
|
|
68
|
+
|
|
69
|
+
bounds: ContextBounds
|
|
70
|
+
entries: list[EntryRecord]
|
|
71
|
+
view: ContextView
|
|
72
|
+
edited: set[EntryId]
|
|
73
|
+
settled: list[MessageRecord]
|
|
74
|
+
open: list[MessageRecord]
|
|
75
|
+
baseline: Baseline
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ContextSession(Protocol):
|
|
79
|
+
"""The Session seam a context read needs: run a job on the mutation line."""
|
|
80
|
+
|
|
81
|
+
async def read_on_line(self, job: Callable[[], Awaitable[Any]]) -> Any: ...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def capture_context_bounds(
|
|
85
|
+
storage: Storage,
|
|
86
|
+
conversation_id: ConversationId,
|
|
87
|
+
context: Context,
|
|
88
|
+
at: EntryId | None = None,
|
|
89
|
+
) -> ContextBounds | None:
|
|
90
|
+
"""Capture the bounds of the current context, or of the context cut off at the visible entry ``at``."""
|
|
91
|
+
|
|
92
|
+
tail: EntryId
|
|
93
|
+
if at is None:
|
|
94
|
+
page = await storage.scan_entries({"conversationId": conversation_id}, 1, None, context)
|
|
95
|
+
if not page["items"]:
|
|
96
|
+
return None
|
|
97
|
+
tail = page["items"][0]["id"]
|
|
98
|
+
else:
|
|
99
|
+
if await storage.entry(conversation_id, at, context) is None:
|
|
100
|
+
raise ValueError(f"Entry {at} is not visible from conversation {conversation_id}")
|
|
101
|
+
tail = at
|
|
102
|
+
return {
|
|
103
|
+
"head": await storage.find_latest_head_marker(conversation_id, tail, context),
|
|
104
|
+
"tail": tail,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def read_context(
|
|
109
|
+
session: ContextSession,
|
|
110
|
+
storage: Storage,
|
|
111
|
+
conversation_id: ConversationId,
|
|
112
|
+
context: Context,
|
|
113
|
+
at: EntryId | None = None,
|
|
114
|
+
) -> ContextView:
|
|
115
|
+
"""Read one conversation's committed context: bounds on the Session line, entries off it."""
|
|
116
|
+
|
|
117
|
+
bounds = await session.read_on_line(lambda: capture_context_bounds(storage, conversation_id, context, at))
|
|
118
|
+
return await derive_context(storage, conversation_id, bounds, context)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def derive_context(
|
|
122
|
+
storage: Storage,
|
|
123
|
+
conversation_id: ConversationId,
|
|
124
|
+
bounds: ContextBounds | None,
|
|
125
|
+
context: Context,
|
|
126
|
+
) -> ContextView:
|
|
127
|
+
"""Derive the active transcript and model context of one conversation within captured bounds.
|
|
128
|
+
|
|
129
|
+
The newest visible head marker ``H`` fixes the range: from ``H.head`` (or transcript start)
|
|
130
|
+
through the tail. Per target, the newest edit in the range wins. Context entries are ``H``
|
|
131
|
+
followed by the range's non-head entries.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
if bounds is None:
|
|
135
|
+
return empty_view()
|
|
136
|
+
return derive_view(
|
|
137
|
+
bounds,
|
|
138
|
+
await scan_range(storage, conversation_id, range_query(bounds), context),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def empty_view() -> ContextView:
|
|
143
|
+
"""Return the view of a conversation without entries."""
|
|
144
|
+
|
|
145
|
+
return {"head": None, "entries": [], "contributions": [], "messages": []}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def derive_view(bounds: ContextBounds, entries: Sequence[EntryRecord]) -> ContextView:
|
|
149
|
+
"""Derive the context view from the oldest-first entries scanned within ``bounds``."""
|
|
150
|
+
|
|
151
|
+
return derive_range(bounds, entries)["view"]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def derive_range(bounds: ContextBounds, entries: Sequence[EntryRecord]) -> ContextRange:
|
|
155
|
+
"""Derive the context view and the range a later read can extend."""
|
|
156
|
+
|
|
157
|
+
edits: dict[EntryId, ContextEdit] = {}
|
|
158
|
+
# Edits of every entry in the range count, including older head markers `select_active` drops.
|
|
159
|
+
for entry in entries:
|
|
160
|
+
for edit in entry.get("edits") or []:
|
|
161
|
+
edits[edit["target"]] = edit
|
|
162
|
+
active = select_active(bounds["head"], entries)
|
|
163
|
+
contributions = [contribute(entry, edits.get(entry["id"])) for entry in active]
|
|
164
|
+
settled, open_messages = settle([], [message for messages in contributions for message in messages])
|
|
165
|
+
baseline = find_baseline(bounds["head"], active)
|
|
166
|
+
messages = lead_with_system(
|
|
167
|
+
[*settled, *order_tool_results(open_messages)],
|
|
168
|
+
baseline,
|
|
169
|
+
)
|
|
170
|
+
view: ContextView = {
|
|
171
|
+
"head": bounds["head"],
|
|
172
|
+
"entries": list(active),
|
|
173
|
+
"contributions": contributions,
|
|
174
|
+
"messages": messages,
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
"bounds": bounds,
|
|
178
|
+
"entries": list(entries),
|
|
179
|
+
"view": view,
|
|
180
|
+
"edited": set(edits),
|
|
181
|
+
"settled": settled,
|
|
182
|
+
"open": open_messages,
|
|
183
|
+
"baseline": baseline,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def read_context_from(
|
|
188
|
+
session: Any,
|
|
189
|
+
storage: Storage,
|
|
190
|
+
conversation_id: ConversationId,
|
|
191
|
+
context: Context,
|
|
192
|
+
at: EntryId | None,
|
|
193
|
+
previous: ContextRange | None,
|
|
194
|
+
) -> tuple[ContextView, ContextRange | None]:
|
|
195
|
+
"""Read the context, reusing ``previous``: with the same head, only newer entries are scanned."""
|
|
196
|
+
|
|
197
|
+
bounds = await session.read_on_line(lambda: capture_context_bounds(storage, conversation_id, context, at))
|
|
198
|
+
if bounds is None:
|
|
199
|
+
return empty_view(), None
|
|
200
|
+
freeze_json(bounds["head"])
|
|
201
|
+
if previous is None or _head_id(previous["bounds"]) != _head_id(bounds):
|
|
202
|
+
scanned = await scan_range(storage, conversation_id, range_query(bounds), context)
|
|
203
|
+
range_value = derive_range(bounds, cast(list[EntryRecord], freeze_json(scanned)))
|
|
204
|
+
elif bounds["tail"] == previous["bounds"]["tail"]:
|
|
205
|
+
range_value = previous
|
|
206
|
+
elif bounds["tail"] < previous["bounds"]["tail"]:
|
|
207
|
+
range_value = derive_range(
|
|
208
|
+
bounds, [entry for entry in previous["entries"] if entry["id"] <= bounds["tail"]]
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
min_entry_id = cast(EntryId, previous["bounds"]["tail"] + 1)
|
|
212
|
+
added = await scan_range(
|
|
213
|
+
storage,
|
|
214
|
+
conversation_id,
|
|
215
|
+
{"minEntryId": min_entry_id, "maxEntryId": bounds["tail"]},
|
|
216
|
+
context,
|
|
217
|
+
)
|
|
218
|
+
range_value = extend_range(previous, bounds, cast(list[EntryRecord], freeze_json(added)))
|
|
219
|
+
view = range_value["view"]
|
|
220
|
+
return (
|
|
221
|
+
{
|
|
222
|
+
"head": view["head"],
|
|
223
|
+
"entries": list(view["entries"]),
|
|
224
|
+
"contributions": list(view["contributions"]),
|
|
225
|
+
"messages": list(view["messages"]),
|
|
226
|
+
},
|
|
227
|
+
range_value,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def extend_range(
|
|
232
|
+
previous: ContextRange,
|
|
233
|
+
bounds: ContextBounds,
|
|
234
|
+
added: Sequence[EntryRecord],
|
|
235
|
+
) -> ContextRange:
|
|
236
|
+
"""Extend ``previous`` by entries after its tail under the same head marker."""
|
|
237
|
+
|
|
238
|
+
entries = [*previous["entries"], *added]
|
|
239
|
+
if any("edits" in entry or "head" in entry or entry["id"] in previous["edited"] for entry in added):
|
|
240
|
+
return derive_range(bounds, entries)
|
|
241
|
+
contributions = [contribute(entry, None) for entry in added]
|
|
242
|
+
settled, open_messages = settle(
|
|
243
|
+
previous["settled"],
|
|
244
|
+
[*previous["open"], *[message for messages in contributions for message in messages]],
|
|
245
|
+
)
|
|
246
|
+
baseline = previous["baseline"]
|
|
247
|
+
if baseline is UNDECIDED:
|
|
248
|
+
baseline = find_baseline(bounds["head"], added)
|
|
249
|
+
view: ContextView = {
|
|
250
|
+
"head": bounds["head"],
|
|
251
|
+
"entries": [*previous["view"]["entries"], *added],
|
|
252
|
+
"contributions": [*previous["view"]["contributions"], *contributions],
|
|
253
|
+
"messages": lead_with_system([*settled, *order_tool_results(open_messages)], baseline),
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
"bounds": bounds,
|
|
257
|
+
"entries": entries,
|
|
258
|
+
"view": view,
|
|
259
|
+
"edited": previous["edited"],
|
|
260
|
+
"settled": settled,
|
|
261
|
+
"open": open_messages,
|
|
262
|
+
"baseline": baseline,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _head_id(bounds: ContextBounds) -> EntryId | None:
|
|
267
|
+
head = bounds["head"]
|
|
268
|
+
return None if head is None else head["id"]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def select_active(
|
|
272
|
+
head: EntryWithHead | None,
|
|
273
|
+
entries: Sequence[EntryRecord],
|
|
274
|
+
) -> list[EntryRecord]:
|
|
275
|
+
"""Return the head marker followed by the range's non-head entries, or the whole range."""
|
|
276
|
+
|
|
277
|
+
if head is None:
|
|
278
|
+
return list(entries)
|
|
279
|
+
return [cast(EntryRecord, head), *[entry for entry in entries if "head" not in entry]]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def contribute(entry: EntryRecord, edit: ContextEdit | None) -> list[MessageRecord]:
|
|
283
|
+
"""Return one active entry's model messages after its edit and excluded stop reasons.
|
|
284
|
+
|
|
285
|
+
Tool result ordering has not applied yet.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
if edit is not None and edit["action"] == "omit":
|
|
289
|
+
return []
|
|
290
|
+
if edit is not None and edit["action"] == "replace":
|
|
291
|
+
contributed = edit["messages"]
|
|
292
|
+
else:
|
|
293
|
+
contributed = entry.get("model") or []
|
|
294
|
+
return [
|
|
295
|
+
message
|
|
296
|
+
for message in contributed
|
|
297
|
+
if message["role"] != "assistant" or message.get("stopReason") not in EXCLUDED_STOP_REASONS
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def settle(
|
|
302
|
+
settled: Sequence[MessageRecord],
|
|
303
|
+
open_messages: Sequence[MessageRecord],
|
|
304
|
+
) -> tuple[list[MessageRecord], list[MessageRecord]]:
|
|
305
|
+
"""Move the ordered messages before the last assistant message of ``open`` into ``settled``.
|
|
306
|
+
|
|
307
|
+
``order_tool_results()`` of a sequence equals the concatenation over its parts when each later
|
|
308
|
+
part starts with an assistant message.
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
last = _find_last_index(open_messages, lambda message: message["role"] == "assistant")
|
|
312
|
+
if last <= 0:
|
|
313
|
+
return list(settled), list(open_messages)
|
|
314
|
+
return (
|
|
315
|
+
[*settled, *order_tool_results(open_messages[:last])],
|
|
316
|
+
list(open_messages[last:]),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def find_baseline(
|
|
321
|
+
head: EntryWithHead | None,
|
|
322
|
+
entries: Sequence[EntryRecord],
|
|
323
|
+
) -> Baseline:
|
|
324
|
+
"""Return the baseline system message among ``entries``, oldest first.
|
|
325
|
+
|
|
326
|
+
The baseline is the system message of the first ``pi.system`` entry after the head marker, or
|
|
327
|
+
of the transcript without one, when generation wrote it with every section and tool in effect.
|
|
328
|
+
``None`` when an earlier entry carries a system message or the first ``pi.system`` entry has
|
|
329
|
+
none, and ``UNDECIDED`` when no entry so far settles it.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
# An edit that targets the baseline replaces or omits its messages, so the entry's own system
|
|
333
|
+
# message is never contributed and cannot lead: `lead_with_system()` compares by identity.
|
|
334
|
+
for entry in entries:
|
|
335
|
+
if head is not None and entry["id"] <= head["id"]:
|
|
336
|
+
continue
|
|
337
|
+
if SystemEntry.is_(entry):
|
|
338
|
+
return next(
|
|
339
|
+
(message for message in entry.get("model") or [] if message["role"] == "system"),
|
|
340
|
+
None,
|
|
341
|
+
)
|
|
342
|
+
if head is None and carries_system_message(entry):
|
|
343
|
+
return None
|
|
344
|
+
return UNDECIDED
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def carries_system_message(entry: EntryRecord) -> bool:
|
|
348
|
+
"""Return whether an entry's own messages or its replacement edits include a system message."""
|
|
349
|
+
|
|
350
|
+
def is_system(message: MessageRecord) -> bool:
|
|
351
|
+
return message["role"] == "system"
|
|
352
|
+
|
|
353
|
+
return any(is_system(message) for message in entry.get("model") or []) or any(
|
|
354
|
+
edit["action"] == "replace" and any(is_system(message) for message in edit["messages"])
|
|
355
|
+
for edit in entry.get("edits") or []
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def lead_with_system(
|
|
360
|
+
messages: Sequence[MessageRecord],
|
|
361
|
+
baseline: Baseline,
|
|
362
|
+
) -> list[MessageRecord]:
|
|
363
|
+
"""Move the first system message to the front when only user messages precede it.
|
|
364
|
+
|
|
365
|
+
It also leads when it is the baseline's system message. Providers treat only a leading system
|
|
366
|
+
message as the initial prompt and tool set, so without one a later tool change rewrites the
|
|
367
|
+
request's tool list and invalidates the whole prompt cache. Only the first system message
|
|
368
|
+
moves, so replaying system messages in order gives the same sections and tools. Any other
|
|
369
|
+
system message before an assistant message stays where it was committed: moving it would
|
|
370
|
+
change what it means to the model.
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
index = next(
|
|
374
|
+
(position for position, message in enumerate(messages) if message["role"] == "system"),
|
|
375
|
+
-1,
|
|
376
|
+
)
|
|
377
|
+
if index <= 0:
|
|
378
|
+
return list(messages)
|
|
379
|
+
system = messages[index]
|
|
380
|
+
if system is not baseline and any(message["role"] != "user" for message in messages[:index]):
|
|
381
|
+
return list(messages)
|
|
382
|
+
return [system, *messages[:index], *messages[index + 1 :]]
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def order_tool_results(messages: Sequence[MessageRecord]) -> list[MessageRecord]:
|
|
386
|
+
"""Place each assistant's tool results directly after it in call order.
|
|
387
|
+
|
|
388
|
+
Results are taken from the messages before the next assistant; a missing result is synthesized
|
|
389
|
+
and unmatched results are dropped.
|
|
390
|
+
"""
|
|
391
|
+
|
|
392
|
+
ordered: list[MessageRecord] = []
|
|
393
|
+
for index, message in enumerate(messages):
|
|
394
|
+
if message["role"] == "toolResult":
|
|
395
|
+
continue
|
|
396
|
+
ordered.append(message)
|
|
397
|
+
if message["role"] != "assistant":
|
|
398
|
+
continue
|
|
399
|
+
calls = [
|
|
400
|
+
content
|
|
401
|
+
for content in cast(list[dict[str, Any]], message["content"])
|
|
402
|
+
if content.get("type") == "toolCall"
|
|
403
|
+
]
|
|
404
|
+
if not calls:
|
|
405
|
+
continue
|
|
406
|
+
results: dict[str, int] = {}
|
|
407
|
+
for next_index in range(index + 1, len(messages)):
|
|
408
|
+
candidate = messages[next_index]
|
|
409
|
+
if candidate["role"] == "assistant":
|
|
410
|
+
break
|
|
411
|
+
tool_call_id = candidate.get("toolCallId")
|
|
412
|
+
if (
|
|
413
|
+
candidate["role"] == "toolResult"
|
|
414
|
+
and isinstance(tool_call_id, str)
|
|
415
|
+
and tool_call_id not in results
|
|
416
|
+
):
|
|
417
|
+
results[tool_call_id] = next_index
|
|
418
|
+
for call in calls:
|
|
419
|
+
result_index = results.get(cast(str, call["id"]))
|
|
420
|
+
ordered.append(
|
|
421
|
+
missing_result(call, cast("int | None", message.get("timestamp")))
|
|
422
|
+
if result_index is None
|
|
423
|
+
else messages[result_index]
|
|
424
|
+
)
|
|
425
|
+
return ordered
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def missing_result(call: dict[str, Any], timestamp: int | None) -> MessageRecord:
|
|
429
|
+
"""Return the frozen error result for a call whose result the visible history lacks."""
|
|
430
|
+
|
|
431
|
+
return cast(
|
|
432
|
+
MessageRecord,
|
|
433
|
+
freeze_json(
|
|
434
|
+
{
|
|
435
|
+
"role": "toolResult",
|
|
436
|
+
"toolCallId": call["id"],
|
|
437
|
+
"toolName": call["name"],
|
|
438
|
+
"content": [{"type": "text", "text": MISSING_RESULT_TEXT}],
|
|
439
|
+
"isError": True,
|
|
440
|
+
"details": {"reason": "missing_result"},
|
|
441
|
+
"timestamp": timestamp,
|
|
442
|
+
}
|
|
443
|
+
),
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def range_query(bounds: ContextBounds) -> EntryRange:
|
|
448
|
+
"""Return the visible range of ``bounds``: from the head marker's head, or transcript start."""
|
|
449
|
+
|
|
450
|
+
head = bounds["head"]
|
|
451
|
+
if head is None:
|
|
452
|
+
return {"maxEntryId": bounds["tail"]}
|
|
453
|
+
return {"minEntryId": head["head"], "maxEntryId": bounds["tail"]}
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
async def scan_range(
|
|
457
|
+
storage: Storage,
|
|
458
|
+
conversation_id: ConversationId,
|
|
459
|
+
entry_range: EntryRange,
|
|
460
|
+
context: Context,
|
|
461
|
+
) -> list[EntryRecord]:
|
|
462
|
+
"""Return the visible entries within ``entry_range``, oldest first."""
|
|
463
|
+
|
|
464
|
+
entries: list[EntryRecord] = []
|
|
465
|
+
cursor: Cursor | None = None
|
|
466
|
+
while True:
|
|
467
|
+
page = await storage.scan_entries(
|
|
468
|
+
cast(EntryQuery, {**entry_range, "conversationId": conversation_id}),
|
|
469
|
+
_SCAN_PAGE_SIZE,
|
|
470
|
+
cursor,
|
|
471
|
+
context,
|
|
472
|
+
)
|
|
473
|
+
entries.extend(page["items"])
|
|
474
|
+
if "next" not in page:
|
|
475
|
+
break
|
|
476
|
+
cursor = page["next"]
|
|
477
|
+
entries.reverse()
|
|
478
|
+
return entries
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _find_last_index(values: Sequence[Any], predicate: Callable[[Any], bool]) -> int:
|
|
482
|
+
for index in range(len(values) - 1, -1, -1):
|
|
483
|
+
if predicate(values[index]):
|
|
484
|
+
return index
|
|
485
|
+
return -1
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
__all__ = [
|
|
489
|
+
"EXCLUDED_STOP_REASONS",
|
|
490
|
+
"MISSING_RESULT_TEXT",
|
|
491
|
+
"Baseline",
|
|
492
|
+
"ContextBounds",
|
|
493
|
+
"ContextRange",
|
|
494
|
+
"ContextSession",
|
|
495
|
+
"EntryRange",
|
|
496
|
+
"UNDECIDED",
|
|
497
|
+
"capture_context_bounds",
|
|
498
|
+
"carries_system_message",
|
|
499
|
+
"contribute",
|
|
500
|
+
"derive_context",
|
|
501
|
+
"derive_range",
|
|
502
|
+
"derive_view",
|
|
503
|
+
"empty_view",
|
|
504
|
+
"extend_range",
|
|
505
|
+
"find_baseline",
|
|
506
|
+
"lead_with_system",
|
|
507
|
+
"missing_result",
|
|
508
|
+
"order_tool_results",
|
|
509
|
+
"read_context",
|
|
510
|
+
"read_context_from",
|
|
511
|
+
"range_query",
|
|
512
|
+
"scan_range",
|
|
513
|
+
"select_active",
|
|
514
|
+
"settle",
|
|
515
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Typed constructors for extensions, tools, sections, and hooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .types import (
|
|
9
|
+
AnyTask,
|
|
10
|
+
Extension,
|
|
11
|
+
HookRegistration,
|
|
12
|
+
PromptSection,
|
|
13
|
+
ToolRegistration,
|
|
14
|
+
Wrap,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _values(value: Mapping[str, Any] | None, kwargs: Mapping[str, Any]) -> dict[str, Any]:
|
|
19
|
+
result = dict(value or {})
|
|
20
|
+
result.update(kwargs)
|
|
21
|
+
return result
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def define_extension(
|
|
25
|
+
extension: Mapping[str, Any] | None = None,
|
|
26
|
+
/,
|
|
27
|
+
**kwargs: Any,
|
|
28
|
+
) -> Extension:
|
|
29
|
+
"""Identity constructor that types an extension."""
|
|
30
|
+
|
|
31
|
+
values = _values(extension, kwargs)
|
|
32
|
+
name = values.get("name")
|
|
33
|
+
if not isinstance(name, str) or not name:
|
|
34
|
+
raise TypeError("Extension name must be a non-empty string")
|
|
35
|
+
return Extension(
|
|
36
|
+
name=name,
|
|
37
|
+
tools=tuple(values.get("tools") or ()),
|
|
38
|
+
sections=tuple(values.get("sections") or ()),
|
|
39
|
+
hooks=tuple(values.get("hooks") or ()),
|
|
40
|
+
wraps=tuple(values.get("wraps") or ()),
|
|
41
|
+
tasks=tuple(values.get("tasks") or ()),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def define_tool(
|
|
46
|
+
tool: Mapping[str, Any] | None = None,
|
|
47
|
+
/,
|
|
48
|
+
**kwargs: Any,
|
|
49
|
+
) -> ToolRegistration:
|
|
50
|
+
"""Identity constructor that types a tool."""
|
|
51
|
+
|
|
52
|
+
values = _values(tool, kwargs)
|
|
53
|
+
name = values.get("name")
|
|
54
|
+
if not isinstance(name, str) or not name:
|
|
55
|
+
raise TypeError("Tool name must be a non-empty string")
|
|
56
|
+
return ToolRegistration(
|
|
57
|
+
name=name,
|
|
58
|
+
description=str(values.get("description") or ""),
|
|
59
|
+
parameters=values.get("parameters"),
|
|
60
|
+
execute=values.get("execute"),
|
|
61
|
+
replay=values.get("replay"),
|
|
62
|
+
execution_mode=_field(values, "execution_mode", "executionMode"),
|
|
63
|
+
structured_output_schema=_field(
|
|
64
|
+
values, "structured_output_schema", "structuredOutputSchema"
|
|
65
|
+
),
|
|
66
|
+
callers=values.get("callers"),
|
|
67
|
+
deferred=values.get("deferred") is True,
|
|
68
|
+
output_limits=_field(values, "output_limits", "outputLimits"),
|
|
69
|
+
constrained_sampling=_field(values, "constrained_sampling", "constrainedSampling"),
|
|
70
|
+
prepare_arguments=_field(values, "prepare_arguments", "prepareArguments"),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _field(values: Mapping[str, Any], name: str, camel: str) -> Any:
|
|
75
|
+
"""A Python-facing snake_case field, accepting the upstream camelCase spelling too."""
|
|
76
|
+
|
|
77
|
+
value = values.get(name)
|
|
78
|
+
return values.get(camel) if value is None else value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def section(
|
|
82
|
+
key: str,
|
|
83
|
+
render: Callable[..., Any],
|
|
84
|
+
options: Mapping[str, Any] | None = None,
|
|
85
|
+
**kwargs: Any,
|
|
86
|
+
) -> PromptSection:
|
|
87
|
+
"""Create a prompt section; tagged unless ``tag`` is false."""
|
|
88
|
+
|
|
89
|
+
values = {**(options or {}), **kwargs}
|
|
90
|
+
return PromptSection(key=key, render=render, tag=values.get("tag"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def hook(task: AnyTask, handlers: Mapping[str, Any]) -> HookRegistration:
|
|
94
|
+
"""Hook handlers for tasks named like ``task``."""
|
|
95
|
+
|
|
96
|
+
return HookRegistration(task=task.definition.name, handlers=dict(handlers))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def wrap_tool(tool: ToolRegistration, wrapper: Callable[[ToolRegistration], ToolRegistration]) -> Wrap:
|
|
100
|
+
"""Wrap the tool named like ``tool`` wherever the wrapping extension is selected."""
|
|
101
|
+
|
|
102
|
+
return Wrap(tool=tool.name, wrap=wrapper)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def wrap_section(key: str, wrapper: Callable[[PromptSection], PromptSection]) -> Wrap:
|
|
106
|
+
"""Wrap the section ``key`` wherever the wrapping extension is selected."""
|
|
107
|
+
|
|
108
|
+
return Wrap(section=key, wrap=wrapper)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = ["define_extension", "define_tool", "hook", "section", "wrap_section", "wrap_tool"]
|