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