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,766 @@
1
+ """Experimental agent events: committed conversation changes as coding-agent events (spec §9.4).
2
+
3
+ ``watch_events()`` translates one conversation's committed publications into batches shaped like
4
+ the coding agent's session events. It owns no tracker or persistence: every event follows the
5
+ commit that makes it true. The stream buffers at most 100 undelivered batches and replaces them
6
+ with one ``snapshot`` beyond that, so it converges without promising every transition.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Awaitable, Callable
12
+ from typing import TYPE_CHECKING, Any, Literal, NotRequired, Protocol, TypeAlias, TypedDict, cast
13
+
14
+ from ..context import Context
15
+ from ..delta import Op
16
+ from ..ids import ConversationId, SubmissionId, TaskId
17
+ from ..session.observation import CommittedWatch
18
+ from ..types import (
19
+ CommitPublication,
20
+ EntryRecord,
21
+ Storage,
22
+ SubmissionRecord,
23
+ )
24
+ from .agent import AgentDoc
25
+ from .inbox import InboxState
26
+ from .live import CompactionFailure, CompactionStatus, LiveState, NestedToolSlot, ToolSlot
27
+ from .scheduler import json_equal
28
+ from .tool import NestedCallDoc, NestedCallState
29
+ from .types import (
30
+ AgentState,
31
+ NestedToolExecutionResult,
32
+ )
33
+ from .usage import UsageDoc, UsageState
34
+ from .util import cancellation_error, scan_all
35
+ from .view import ConversationView, ConversationViews
36
+
37
+ if TYPE_CHECKING:
38
+ from .harness import Harness
39
+
40
+
41
+ class QueuedItem(TypedDict):
42
+ """One queued submission as the agent events show it."""
43
+
44
+ id: SubmissionId
45
+ mode: Literal["steer", "followUp", "write"]
46
+
47
+
48
+ class SnapshotEvent(TypedDict):
49
+ """Everything a consumer should hold, captured atomically at attachment."""
50
+
51
+ type: Literal["snapshot"]
52
+ entries: list[EntryRecord]
53
+ run: NotRequired[dict[str, list[SubmissionId]]]
54
+ generation: NotRequired[dict[str, Any]]
55
+ tools: list[ToolSlot]
56
+ nestedTools: list[NestedToolSlot]
57
+ compactions: list[CompactionStatus]
58
+ compactionFailure: NotRequired[CompactionFailure]
59
+ inbox: list[QueuedItem]
60
+ agent: AgentState
61
+ usage: UsageState
62
+
63
+
64
+ class ToolEventCall(TypedDict):
65
+ """Which call a tool event is about."""
66
+
67
+ toolCallId: str
68
+ toolName: str
69
+ taskId: NotRequired[TaskId]
70
+ parentToolCallId: NotRequired[str]
71
+ parentTaskId: NotRequired[TaskId]
72
+
73
+
74
+ MessageChange: TypeAlias = dict[str, Any]
75
+ """One change to the in-flight assistant message, relative to that message."""
76
+
77
+ AgentEvent: TypeAlias = dict[str, Any]
78
+ """One translated event; every variant carries ``type`` in the coding agent's vocabulary."""
79
+
80
+
81
+ class AgentEventStream(Protocol):
82
+ """Serialized stream of one conversation's event batches, one per commit."""
83
+
84
+ @property
85
+ def snapshot(self) -> SnapshotEvent: ...
86
+
87
+ def start(self, listener: Callable[[list[AgentEvent], Context], Awaitable[None] | None]) -> None: ...
88
+
89
+ def stop(self) -> Any: ...
90
+
91
+ @property
92
+ def closed(self) -> Any: ...
93
+
94
+
95
+ class _EventStream:
96
+ """One attached stream: its snapshot, its watch, and the delivery adapter."""
97
+
98
+ __slots__ = ("_snapshot", "_watch")
99
+
100
+ def __init__(self, snapshot: SnapshotEvent, watch: CommittedWatch) -> None:
101
+ self._snapshot = snapshot
102
+ self._watch = watch
103
+
104
+ @property
105
+ def snapshot(self) -> SnapshotEvent:
106
+ return self._snapshot
107
+
108
+ def start(
109
+ self, listener: Callable[[list[AgentEvent], Context], Awaitable[None] | None]
110
+ ) -> None:
111
+ def deliver(events: list[AgentEvent], _ops: list[Op], context: Context) -> Any:
112
+ return listener(events, context)
113
+
114
+ self._watch.start(deliver)
115
+
116
+ def stop(self) -> Any:
117
+ return self._watch.stop()
118
+
119
+ @property
120
+ def closed(self) -> Any:
121
+ return self._watch.closed
122
+
123
+
124
+ class _EventsObserver:
125
+ """The view observer that turns each publication into one event batch."""
126
+
127
+ __slots__ = ("_conversation_id", "_current", "_held", "_watch")
128
+
129
+ def __init__(
130
+ self,
131
+ conversation_id: ConversationId,
132
+ current: list[ConversationView],
133
+ held: set[TaskId],
134
+ watch: CommittedWatch,
135
+ ) -> None:
136
+ self._conversation_id = conversation_id
137
+ self._current = current
138
+ self._held = held
139
+ self._watch = watch
140
+
141
+ def publication(
142
+ self,
143
+ before: ConversationView,
144
+ after: ConversationView,
145
+ ops: list[Op],
146
+ publication: CommitPublication,
147
+ context: Context,
148
+ ) -> None:
149
+ self._current[0] = after
150
+ events = translate(
151
+ self._conversation_id, before, after, ops, publication, self._held
152
+ )
153
+ if events:
154
+ self._watch.advance_frame(events, [], context)
155
+
156
+ def close_session(self, failure: BaseException | None) -> None:
157
+ self._watch.close_session(failure)
158
+
159
+
160
+ async def watch_events(
161
+ harness: Harness,
162
+ conversation_id: ConversationId,
163
+ context: Context,
164
+ ) -> AgentEventStream:
165
+ """Attach to one conversation's agent events (spec §9.4)."""
166
+
167
+ views: ConversationViews = harness._views
168
+ holder: dict[str, Any] = {}
169
+
170
+ async def create(
171
+ initial: ConversationView,
172
+ release: Callable[[], None],
173
+ storage: Storage,
174
+ attached_views: ConversationViews,
175
+ ) -> _EventsObserver:
176
+ # Generations whose held outcome already ended their turn, read on the line with the snapshot.
177
+ completing = await scan_all(
178
+ lambda cursor: storage.scan_tasks(
179
+ {
180
+ "conversationId": conversation_id,
181
+ "kind": "pi.generation",
182
+ "status": "completing",
183
+ },
184
+ 100,
185
+ cursor,
186
+ context,
187
+ )
188
+ )
189
+ held = {record["id"] for record in completing}
190
+ current = [initial]
191
+ snapshot = _snapshot_of(initial)
192
+ watch = CommittedWatch(
193
+ [],
194
+ release,
195
+ attached_views.report,
196
+ replace=lambda: [_snapshot_of(current[0])],
197
+ )
198
+ holder["snapshot"] = snapshot
199
+ holder["watch"] = watch
200
+ return _EventsObserver(conversation_id, current, held, watch)
201
+
202
+ await views.attach(conversation_id, create, context)
203
+ watch = holder["watch"]
204
+ signal = context.abort_signal
205
+ if signal is not None and signal.aborted:
206
+ watch.cancel()
207
+ raise cancellation_error(signal)
208
+ if signal is not None:
209
+ watch.observe_cancellation(signal)
210
+ return _EventStream(holder["snapshot"], watch)
211
+
212
+
213
+ def _parts(view: ConversationView) -> dict[str, Any]:
214
+ docs = view["docs"]
215
+ return {
216
+ "live": docs.get("pi.live") or {},
217
+ "inbox": docs.get("pi.inbox"),
218
+ "agent": docs.get("pi.agent"),
219
+ "usage": docs.get("pi.usage"),
220
+ }
221
+
222
+
223
+ def _snapshot_of(view: ConversationView) -> SnapshotEvent:
224
+ parts = _parts(view)
225
+ live: LiveState = parts["live"]
226
+ snapshot = SnapshotEvent(
227
+ type="snapshot",
228
+ entries=list(view["entries"]),
229
+ tools=list(live.get("tools") or ()),
230
+ nestedTools=list(live.get("nestedTools") or ()),
231
+ compactions=list(live.get("compactions") or ()),
232
+ inbox=_queued(parts["inbox"]),
233
+ agent=cast(
234
+ AgentState,
235
+ parts["agent"] if parts["agent"] is not None else AgentDoc.definition.initial(),
236
+ ),
237
+ usage=cast(
238
+ UsageState,
239
+ parts["usage"] if parts["usage"] is not None else UsageDoc.definition.initial(),
240
+ ),
241
+ )
242
+ run = live.get("run")
243
+ if run is not None:
244
+ snapshot["run"] = {"inputs": list(run["inputs"])}
245
+ # A consumer holding the snapshot must not be able to mutate the committed document.
246
+ generation = live.get("generation")
247
+ if generation is not None:
248
+ snapshot["generation"] = generation
249
+ failure = live.get("compactionFailure")
250
+ if failure is not None:
251
+ snapshot["compactionFailure"] = failure
252
+ return snapshot
253
+
254
+
255
+ def _queued(inbox: InboxState | None) -> list[QueuedItem]:
256
+ items = inbox["items"] if inbox is not None else []
257
+ return [{"id": item["id"], "mode": item["mode"]} for item in items]
258
+
259
+
260
+ def _result_of(entries: list[EntryRecord], call_id: str) -> EntryRecord | None:
261
+ for entry in entries:
262
+ model = entry.get("model")
263
+ message = model[0] if model else None
264
+ if (
265
+ message is not None
266
+ and message.get("role") == "toolResult"
267
+ and message.get("toolCallId") == call_id
268
+ ):
269
+ return entry
270
+ return None
271
+
272
+
273
+ def translate(
274
+ conversation_id: ConversationId,
275
+ before: ConversationView,
276
+ after: ConversationView,
277
+ view_ops: list[Op],
278
+ publication: CommitPublication,
279
+ held: set[TaskId],
280
+ ) -> list[AgentEvent]:
281
+ """Every event one publication causes, in the order of spec §9.4."""
282
+
283
+ entries: list[EntryRecord] = []
284
+ tasks: dict[TaskId, Any] = {}
285
+ submissions: list[SubmissionRecord] = []
286
+ # Nested results this commit stored, by nested task ID; read now, since the caller's
287
+ # documents may retire next.
288
+ nested_results: dict[TaskId, NestedToolExecutionResult] = {}
289
+ for change in publication["changes"]:
290
+ if (
291
+ change["type"] == "document"
292
+ and change["record"]["kind"] == NestedCallDoc.definition.kind
293
+ and change["value"] is not None
294
+ and change["conversationId"] == conversation_id
295
+ ):
296
+ nested_state = cast(NestedCallState, change["value"])
297
+ task_id = nested_state.get("taskId")
298
+ result = cast(NestedToolExecutionResult | None, nested_state.get("result"))
299
+ if task_id is not None and result is not None:
300
+ nested_results[task_id] = result
301
+ if change["type"] == "entry" and change["value"]["conversationId"] == conversation_id:
302
+ entries.append(change["value"])
303
+ if change["type"] == "task" and change["value"]["conversationId"] == conversation_id:
304
+ tasks[change["value"]["id"]] = change["value"]
305
+ if change["type"] == "submission" and change["value"]["conversationId"] == conversation_id:
306
+ submissions.append(change["value"])
307
+ if not view_ops and not entries and not tasks and not submissions:
308
+ return []
309
+ # Entries are appended in ID order; submission records are published in the order the commit touched them.
310
+ submissions.sort(key=lambda record: record["id"])
311
+ was = _parts(before)
312
+ now = _parts(after)
313
+ events: list[AgentEvent] = []
314
+
315
+ # Progress: tool starts, the in-flight message, tool updates, retry and deferred state.
316
+ slots_before: dict[str, Any] = {
317
+ str(slot["callId"]): slot for slot in (was["live"].get("tools") or ())
318
+ }
319
+ slots: list[Any] = list(now["live"].get("tools") or ())
320
+ nested_before: dict[TaskId, Any] = {
321
+ cast(TaskId, slot["taskId"]): slot
322
+ for slot in (was["live"].get("nestedTools") or ())
323
+ }
324
+ nested: list[Any] = list(now["live"].get("nestedTools") or ())
325
+ for candidate in [*slots, *nested]:
326
+ previous_slot = (
327
+ nested_before.get(candidate["taskId"])
328
+ if "parentCallId" in candidate
329
+ else slots_before.get(candidate["callId"])
330
+ )
331
+ if candidate.get("status") != "running" or (previous_slot or {}).get("status") == "running":
332
+ continue
333
+ # A nested slot carries the arguments the call runs with; a model-issued call's are in
334
+ # its intent checkpoint.
335
+ task_id = candidate.get("taskId")
336
+ task = tasks.get(task_id) if task_id is not None else None
337
+ if "parentCallId" in candidate:
338
+ args = candidate.get("arguments") or {}
339
+ else:
340
+ record = cast(dict[str, Any], task or {})
341
+ checkpoint = cast(dict[str, Any], record.get("state") or {}).get("checkpoint") or {}
342
+ args = checkpoint.get("arguments") or {}
343
+ events.append({"type": "tool_execution_start", **_call_of(candidate), "args": args})
344
+
345
+ partial_before = (was["live"].get("generation") or {}).get("message")
346
+ partial = (now["live"].get("generation") or {}).get("message")
347
+ if partial is not None and partial_before is None:
348
+ events.append({"type": "message_start", "message": partial})
349
+ elif partial is not None and not json_equal(partial, partial_before):
350
+ events.append(
351
+ {
352
+ "type": "message_update",
353
+ "usage": partial.get("usage") or {},
354
+ "changes": _message_changes(view_ops, partial),
355
+ }
356
+ )
357
+ for index, current_slot in enumerate(slots):
358
+ previous_slot = slots_before.get(current_slot["callId"])
359
+ if (
360
+ current_slot.get("status") != "running"
361
+ or (previous_slot or {}).get("status") != "running"
362
+ ):
363
+ continue
364
+ update = _tool_update(view_ops, ["tools", index], current_slot, previous_slot or {})
365
+ if update is not None:
366
+ events.append(
367
+ {"type": "tool_execution_update", **_call_of(current_slot), **update}
368
+ )
369
+ for index, current_nested in enumerate(nested):
370
+ previous_nested = nested_before.get(current_nested["taskId"])
371
+ if (
372
+ current_nested.get("status") != "running"
373
+ or (previous_nested or {}).get("status") != "running"
374
+ ):
375
+ continue
376
+ update = _tool_update(
377
+ view_ops, ["nestedTools", index], current_nested, previous_nested or {}
378
+ )
379
+ if update is not None:
380
+ events.append(
381
+ {"type": "tool_execution_update", **_call_of(current_nested), **update}
382
+ )
383
+
384
+ generation = now["live"].get("generation")
385
+ generation_before = was["live"].get("generation")
386
+ if (
387
+ generation is not None
388
+ and generation.get("retry") is not None
389
+ and (generation_before or {}).get("retry") is None
390
+ ):
391
+ retry = generation["retry"]
392
+ events.append(
393
+ {
394
+ "type": "auto_retry_start",
395
+ "attempt": generation.get("attempt"),
396
+ "at": retry["at"],
397
+ "errorMessage": retry["error"],
398
+ }
399
+ )
400
+ if (
401
+ generation_before is not None
402
+ and generation_before.get("retry") is not None
403
+ and (generation or {}).get("retry") is None
404
+ ):
405
+ events.append({"type": "auto_retry_end", "attempt": generation_before.get("attempt")})
406
+ deferred = (generation or {}).get("deferred")
407
+ deferred_before = (generation_before or {}).get("deferred") or {}
408
+ if deferred is not None and deferred.get("pollAt") != deferred_before.get("pollAt"):
409
+ events.append({"type": "deferred_poll", "pollAt": deferred["pollAt"]})
410
+
411
+ # Tools that end in this commit: a slot that becomes done, one created done, or an unfinished one
412
+ # that vanishes because its run ended. A done slot that vanishes ended earlier.
413
+ tool_ends: list[AgentEvent] = []
414
+
415
+ def end_tool(slot: Any, entry_id: int | None) -> None:
416
+ entry = next((entry for entry in entries if entry["id"] == entry_id), None)
417
+ tool_ends.append(
418
+ {
419
+ "type": "tool_execution_end",
420
+ **_call_of(slot),
421
+ **({} if entry is None else {"entry": entry}),
422
+ }
423
+ )
424
+
425
+ for previous_slot in slots_before.values():
426
+ if previous_slot.get("status") == "done":
427
+ continue
428
+ matching = next(
429
+ (
430
+ candidate
431
+ for candidate in slots
432
+ if candidate["callId"] == previous_slot["callId"]
433
+ ),
434
+ None,
435
+ )
436
+ if matching is not None and matching.get("status") == "done":
437
+ end_tool(matching, matching.get("entry"))
438
+ elif matching is None:
439
+ result_entry = _result_of(entries, previous_slot["callId"])
440
+ end_tool(previous_slot, None if result_entry is None else result_entry["id"])
441
+ for current_slot in slots:
442
+ if (
443
+ current_slot.get("status") == "done"
444
+ and current_slot["callId"] not in slots_before
445
+ ):
446
+ end_tool(current_slot, current_slot.get("entry"))
447
+
448
+ # Nested calls that end in this commit, the same way, children before the calls that made them:
449
+ # the lists hold parents first, so walk them backwards.
450
+ def end_nested(slot: Any) -> None:
451
+ result = nested_results.get(cast(TaskId, slot["taskId"]))
452
+ events.append(
453
+ {
454
+ "type": "tool_execution_end",
455
+ **_call_of(slot),
456
+ **({} if result is None else {"result": result}),
457
+ }
458
+ )
459
+
460
+ nested_now: dict[TaskId, Any] = {
461
+ cast(TaskId, slot["taskId"]): slot for slot in nested
462
+ }
463
+ for previous_nested in reversed(list(nested_before.values())):
464
+ if previous_nested.get("status") == "done":
465
+ continue
466
+ matching_nested = nested_now.get(previous_nested["taskId"])
467
+ if matching_nested is not None and matching_nested.get("status") == "done":
468
+ end_nested(matching_nested)
469
+ elif matching_nested is None:
470
+ end_nested(previous_nested)
471
+ for current_nested in reversed(nested):
472
+ if (
473
+ current_nested.get("status") == "done"
474
+ and current_nested["taskId"] not in nested_before
475
+ ):
476
+ end_nested(current_nested)
477
+
478
+ # Entries in append order; a tool's end directly precedes its result's message, as in the coding agent.
479
+ assistant_appended = False
480
+ for entry in entries:
481
+ for end in tool_ends:
482
+ if end.get("entry") is entry:
483
+ events.append(end)
484
+ model = entry.get("model")
485
+ message = model[0] if model else None
486
+ if message is None:
487
+ events.append({"type": "entry_appended", "entry": entry})
488
+ continue
489
+ # A streamed answer already started with its first partial.
490
+ streamed = (
491
+ message.get("role") == "assistant"
492
+ and partial_before is not None
493
+ and not assistant_appended
494
+ )
495
+ if message.get("role") == "assistant":
496
+ assistant_appended = True
497
+ if not streamed:
498
+ events.append({"type": "message_start", "message": message})
499
+ events.append({"type": "message_end", "entry": entry})
500
+ # Ends without a result entry: a faulted or orphaned tool, or one whose run ended.
501
+ for end in tool_ends:
502
+ if "entry" not in end:
503
+ events.append(end)
504
+
505
+ # Compaction ends, task failures, then turn and run ends.
506
+ compactions_before: list[Any] = list(was["live"].get("compactions") or ())
507
+ compactions: list[Any] = list(now["live"].get("compactions") or ())
508
+ failure: Any = now["live"].get("compactionFailure")
509
+ for status in compactions_before:
510
+ if not any(current["taskId"] == status["taskId"] for current in compactions):
511
+ error = (
512
+ failure["error"]
513
+ if failure is not None and failure["taskId"] == status["taskId"]
514
+ else None
515
+ )
516
+ events.append(
517
+ {
518
+ "type": "compaction_end",
519
+ "taskId": status["taskId"],
520
+ "reason": status["reason"],
521
+ **({} if error is None else {"error": error}),
522
+ }
523
+ )
524
+ # A generation's turn ends when its outcome is committed: at a `completing` hold or at terminal,
525
+ # whichever comes first, so a successor created at the hold starts after it.
526
+ turn_ended = False
527
+ for task_record in tasks.values():
528
+ task_state = task_record["state"]
529
+ status = task_state["status"]
530
+ if (
531
+ task_record["kind"] == "pi.generation"
532
+ and status == "completing"
533
+ and task_record["id"] not in held
534
+ ):
535
+ held.add(task_record["id"])
536
+ turn_ended = True
537
+ if status != "terminal":
538
+ continue
539
+ if task_record["kind"] == "pi.generation":
540
+ if task_record["id"] in held:
541
+ held.discard(task_record["id"])
542
+ else:
543
+ turn_ended = True
544
+ outcome = task_state["outcome"]
545
+ if outcome["status"] in ("faulted", "orphaned"):
546
+ message = (
547
+ outcome["error"]["message"]
548
+ if outcome["status"] == "faulted"
549
+ else outcome["reason"]
550
+ )
551
+ events.append(
552
+ {
553
+ "type": "task_failed",
554
+ "taskId": task_record["id"],
555
+ "kind": task_record["kind"],
556
+ "message": message,
557
+ }
558
+ )
559
+ if turn_ended:
560
+ events.append({"type": "turn_end"})
561
+ run = now["live"].get("run")
562
+ run_before = was["live"].get("run")
563
+ run_changed = (run or {}).get("inputs", [None])[0] != (run_before or {}).get("inputs", [None])[0]
564
+ if run_before is not None and run_changed:
565
+ events.append({"type": "run_end", "inputs": list(run_before["inputs"])})
566
+
567
+ # Submissions, document state, then what began.
568
+ for submission_record in submissions:
569
+ events.append({"type": "submission", "record": submission_record})
570
+ if now["inbox"] is not was["inbox"]:
571
+ events.append({"type": "inbox_update", "items": _queued(now["inbox"])})
572
+ # A retired document reads as its initial value, as in a snapshot.
573
+ if now["agent"] is not was["agent"]:
574
+ events.append(
575
+ {
576
+ "type": "agent_changed",
577
+ "agent": now["agent"] if now["agent"] is not None else AgentDoc.definition.initial(),
578
+ }
579
+ )
580
+ if now["usage"] is not was["usage"]:
581
+ events.append(
582
+ {
583
+ "type": "usage_changed",
584
+ "usage": now["usage"] if now["usage"] is not None else UsageDoc.definition.initial(),
585
+ }
586
+ )
587
+ for status in compactions:
588
+ if not any(current["taskId"] == status["taskId"] for current in compactions_before):
589
+ events.append(
590
+ {
591
+ "type": "compaction_start",
592
+ "taskId": status["taskId"],
593
+ "reason": status["reason"],
594
+ "blocking": status.get("blocking", False),
595
+ }
596
+ )
597
+ if run is not None and run_changed:
598
+ events.append({"type": "run_start", "inputs": list(run["inputs"])})
599
+ if (
600
+ run is not None
601
+ and run.get("taskId") != (run_before or {}).get("taskId")
602
+ and (tasks.get(run["taskId"]) or {}).get("kind") == "pi.generation"
603
+ ):
604
+ events.append({"type": "turn_start"})
605
+ return events
606
+
607
+
608
+ PARTIAL_PATH = ["docs", "pi.live", "generation", "message"]
609
+
610
+
611
+ def _message_changes(view_ops: list[Op], message: dict[str, Any]) -> list[MessageChange]:
612
+ """Translate the view operations on the in-flight message into message changes."""
613
+
614
+ changes: list[MessageChange] = []
615
+ # A block sent whole already holds every later change to it in this batch.
616
+ whole: set[int] = set()
617
+ for op in view_ops:
618
+ if op[0] == "r":
619
+ continue
620
+ path = op[1]
621
+ if not _starts_with(path, PARTIAL_PATH):
622
+ # The whole message or generation was replaced.
623
+ if _starts_with(PARTIAL_PATH, path):
624
+ return [{"type": "message", "message": message}]
625
+ continue
626
+ rest = path[len(PARTIAL_PATH):]
627
+ if not rest:
628
+ return [{"type": "message", "message": message}]
629
+ if rest[0] == "usage":
630
+ continue
631
+ if rest[0] != "content":
632
+ return [{"type": "message", "message": message}]
633
+ if len(rest) == 1:
634
+ if op[0] != "p" or op[3] != 0:
635
+ return [{"type": "message", "message": message}]
636
+ for offset, block in enumerate(op[4]):
637
+ kind = (
638
+ "text_start"
639
+ if block.get("type") == "text"
640
+ else "thinking_start"
641
+ if block.get("type") == "thinking"
642
+ else "toolcall_start"
643
+ )
644
+ changes.append(
645
+ {"type": kind, "contentIndex": op[2] + offset, "block": block}
646
+ )
647
+ continue
648
+ content_index = rest[1]
649
+ field = rest[2] if len(rest) > 2 else None
650
+ if content_index in whole:
651
+ continue
652
+ if op[0] == "a" and len(rest) == 3 and field in ("text", "thinking"):
653
+ changes.append(
654
+ {
655
+ "type": "text_delta" if field == "text" else "thinking_delta",
656
+ "contentIndex": content_index,
657
+ "delta": op[2],
658
+ }
659
+ )
660
+ elif op[0] == "a" and field == "arguments":
661
+ changes.append(
662
+ {
663
+ "type": "toolcall_delta",
664
+ "contentIndex": content_index,
665
+ "path": rest[3:],
666
+ "delta": op[2],
667
+ }
668
+ )
669
+ else:
670
+ whole.add(content_index)
671
+ changes.append(
672
+ {
673
+ "type": "block",
674
+ "contentIndex": content_index,
675
+ "block": message["content"][content_index],
676
+ }
677
+ )
678
+ return changes
679
+
680
+
681
+ def _call_of(slot: Any) -> ToolEventCall:
682
+ """The identifying fields of a slot's tool events."""
683
+
684
+ call = ToolEventCall(toolCallId=slot["callId"], toolName=slot["name"])
685
+ task_id = slot.get("taskId")
686
+ if task_id is not None:
687
+ call["taskId"] = task_id
688
+ if "parentCallId" in slot:
689
+ call["parentToolCallId"] = slot["parentCallId"]
690
+ call["parentTaskId"] = slot["parentTaskId"]
691
+ return call
692
+
693
+
694
+ _MISSING = object()
695
+
696
+
697
+ def _tool_update(
698
+ view_ops: list[Op],
699
+ at: list[Any],
700
+ slot: Any,
701
+ previous: Any,
702
+ ) -> dict[str, Any] | None:
703
+ """Output, details, and diagnostics changes of a running slot at ``at`` in ``pi.live``."""
704
+
705
+ output_path = ["docs", "pi.live", *at, "output"]
706
+ trim_start = 0
707
+ append = ""
708
+ replaced = False
709
+ for op in view_ops:
710
+ if op[0] == "r" or not _starts_with(op[1], output_path):
711
+ continue
712
+ if op[0] == "t":
713
+ trim_start += op[2]
714
+ elif op[0] == "a":
715
+ append += op[2]
716
+ else:
717
+ replaced = True
718
+ output: dict[str, Any] | None = None
719
+ current_output = slot.get("output")
720
+ if replaced or (
721
+ not json_equal(current_output, previous.get("output"))
722
+ and trim_start == 0
723
+ and append == ""
724
+ ):
725
+ output = {"set": current_output or ""}
726
+ elif trim_start > 0 or append != "":
727
+ output = {}
728
+ if trim_start > 0:
729
+ output["trimStart"] = trim_start
730
+ if append != "":
731
+ output["append"] = append
732
+ update: dict[str, Any] = {}
733
+ if output is not None:
734
+ update["output"] = output
735
+ # A safe replay clears a running slot's progress: removed details send `null`, removed diagnostics `[]`.
736
+ current = slot.get("details", _MISSING)
737
+ prior = previous.get("details", _MISSING)
738
+ if current is _MISSING or prior is _MISSING:
739
+ if current is not prior:
740
+ update["details"] = None if current is _MISSING else current
741
+ elif not json_equal(current, prior):
742
+ update["details"] = current
743
+ current_diagnostics = slot.get("diagnostics", _MISSING)
744
+ prior_diagnostics = previous.get("diagnostics", _MISSING)
745
+ if current_diagnostics is _MISSING or prior_diagnostics is _MISSING:
746
+ if current_diagnostics is not prior_diagnostics:
747
+ update["diagnostics"] = (
748
+ [] if current_diagnostics is _MISSING else current_diagnostics
749
+ )
750
+ elif not json_equal(current_diagnostics, prior_diagnostics):
751
+ update["diagnostics"] = current_diagnostics
752
+ return update or None
753
+
754
+
755
+ def _starts_with(path: Any, prefix: list[Any]) -> bool:
756
+ return isinstance(path, list) and len(prefix) <= len(path) and path[: len(prefix)] == prefix
757
+
758
+
759
+ __all__ = [
760
+ "AgentEvent",
761
+ "AgentEventStream",
762
+ "MessageChange",
763
+ "SnapshotEvent",
764
+ "ToolEventCall",
765
+ "watch_events",
766
+ ]