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