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,1079 @@
1
+ """Built-in ``pi.generation`` task: prepare, request, retry, poll, and classify a model response.
2
+
3
+ Port of v1.3.0 ``src/harness/generation.ts``. The run's inputs live in ``pi.live.run``. Text answers
4
+ stream through the model protocol; partials are throttled durable commits into ``pi.live`` and are
5
+ converted to aborted assistant entries when an attempt is interrupted. A tool-calling answer starts
6
+ a tool round: one ``pi.tool`` task per call, owned by the generation, which the ``tools`` phase
7
+ waits on and then ends with the round's controls. Preparation starts a threshold compaction when the
8
+ context estimate crosses a policy threshold, and classification starts an overflow compaction, both
9
+ owned by the generation so the run waits for the summary.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
16
+ from typing import Any, NotRequired, TypedDict, cast
17
+
18
+ from ..context import Context
19
+ from ..entries import AssistantEntry, ResetEntry, SystemEntry, UserEntry
20
+ from ..ids import ConversationId, EntryId, TaskId
21
+ from ..json import JsonObject, JsonValue, copy_json
22
+ from ..providers import (
23
+ is_context_overflow,
24
+ is_retryable_assistant_error,
25
+ retry_delay_ms,
26
+ )
27
+ from ..session.drafts import DraftList, DraftObject
28
+ from ..session.transaction import Transaction
29
+ from ..tasks import define_task
30
+ from ..types import EntryDraft, MessageRecord, TaskRecord
31
+ from .agent import add_tools
32
+ from .compaction import create_compaction, estimate_context, select_cut
33
+ from .inbox import QueueModes, apply_boundary, prepare_boundary
34
+ from .live import LiveDoc, end_run, start_run
35
+ from .prompt import current_tools, plan_system_entries, render_sections, replay_sections
36
+ from .provider import ensure_provider_session_id
37
+ from .tool import ToolTask, append_tool_result, entry_tool_calls, harness_error
38
+ from .types import ContextView, ModelRef, PromptInput
39
+ from .usage import record_usage
40
+ from .util import assign_json, call_hook, request_stream_options, task_checkpoint
41
+
42
+ DEFAULT_POLL_AFTER_MS = 5_000
43
+
44
+
45
+ class GenerationInput(TypedDict):
46
+ """The run's inputs live in ``pi.live.run``, so the task input is empty."""
47
+
48
+
49
+ class PrepareCheckpoint(TypedDict):
50
+ phase: str
51
+ attempt: int
52
+ compacted: NotRequired[TaskId]
53
+ overflow: NotRequired[str]
54
+ threshold: NotRequired[bool]
55
+
56
+
57
+ class RequestCheckpoint(TypedDict):
58
+ phase: str
59
+ attempt: int
60
+ compacted: NotRequired[TaskId]
61
+ model: ModelRef
62
+ thinkingLevel: str
63
+ streamOptions: dict[str, Any]
64
+ cutoff: EntryId
65
+
66
+
67
+ class RetryCheckpoint(TypedDict):
68
+ phase: str
69
+ attempt: int
70
+ compacted: NotRequired[TaskId]
71
+ until: int
72
+
73
+
74
+ class PollCheckpoint(TypedDict):
75
+ phase: str
76
+ attempt: int
77
+ compacted: NotRequired[TaskId]
78
+ model: ModelRef
79
+ cutoff: EntryId
80
+ handle: JsonObject
81
+ pollAt: int
82
+
83
+
84
+ class ToolsCheckpoint(TypedDict):
85
+ phase: str
86
+ assistant: EntryId
87
+ tools: list[TaskId]
88
+ pending: list[str]
89
+
90
+
91
+ GenerationCheckpoint = (
92
+ PrepareCheckpoint | RequestCheckpoint | RetryCheckpoint | PollCheckpoint | ToolsCheckpoint
93
+ )
94
+ """Checkpoint shapes the generation phases read and write."""
95
+
96
+
97
+ class GenerationResult(TypedDict):
98
+ entryId: EntryId
99
+
100
+
101
+ def _prepare(attempt: int, **extra: Any) -> dict[str, Any]:
102
+ return {"phase": "prepare", "attempt": attempt, **extra}
103
+
104
+
105
+ def _compact_fields(compacted: Any) -> dict[str, Any]:
106
+ return {} if compacted is None else {"compacted": compacted}
107
+
108
+
109
+ async def _prepare_phase(task: TaskRecord, runtime: Any, context: Context) -> None:
110
+ conversation_id = runtime.conversation_id
111
+ agent = await runtime.agent(context)
112
+ settings = runtime.settings
113
+ ref = agent.model
114
+ model = None if ref is None else runtime.models.get_model(ref["provider"], ref["modelId"])
115
+ if ref is None or model is None:
116
+ await fail_no_model(runtime, ref, context)
117
+ return
118
+ checkpoint = task_checkpoint(task)
119
+ attempt = int(checkpoint["attempt"])
120
+ compacted = checkpoint.get("compacted")
121
+ overflow = checkpoint.get("overflow")
122
+ threshold_blocked = checkpoint.get("threshold") is True
123
+ if compacted is not None and overflow is not None:
124
+ outcome = (await runtime.outcomes([compacted], context))[0]
125
+ if outcome.get("status") != "completed" or (
126
+ cast(Mapping[str, Any], outcome.get("result") or {}).get("entryId") is None
127
+ ):
128
+ await fail_model_error(runtime, str(overflow), context)
129
+ return
130
+ if compacted is not None and threshold_blocked:
131
+ outcome = (await runtime.outcomes([compacted], context))[0]
132
+ if outcome.get("status") != "completed":
133
+ await fail_model_error(runtime, compaction_failure(outcome), context)
134
+ return
135
+ view = await runtime.context(conversation_id, context)
136
+ shown = replay_sections(view["messages"])
137
+ report = runtime.report
138
+ env: Any = None
139
+ try:
140
+ env = await runtime.env(context)
141
+ except BaseException as error:
142
+ if context.abort_signal is not None and context.abort_signal.aborted:
143
+ raise
144
+ report(error)
145
+ prompt_input = PromptInput(
146
+ conversation_id=conversation_id,
147
+ agent=agent,
148
+ env=env,
149
+ shown=shown,
150
+ read=runtime,
151
+ )
152
+ desired = await render_sections(agent.sections, prompt_input, shown, report, context)
153
+ entries = plan_system_entries(view, desired, agent.tools, runtime.now())
154
+ threshold = (
155
+ None
156
+ if compacted is not None
157
+ else threshold_compaction(view, entries, model.context_window, settings.compaction)
158
+ )
159
+ if threshold == "blocking":
160
+ # Compact first and prepare again; the transcript is unchanged until the compaction appends.
161
+
162
+ async def compact(tx: Transaction) -> dict[str, Any]:
163
+ child = await create_compaction(tx, conversation_id, {"reason": "threshold"}, runtime.task_id)
164
+ checkpoint_value: dict[str, Any] = {
165
+ "phase": "prepare",
166
+ "attempt": attempt,
167
+ **_compact_fields(compacted),
168
+ "compacted": child,
169
+ "threshold": True,
170
+ }
171
+ return {
172
+ "status": "waiting",
173
+ "checkpoint": checkpoint_value,
174
+ "on": [child],
175
+ "policy": "allSettled",
176
+ }
177
+
178
+ await runtime.commit(compact, context)
179
+ return
180
+
181
+ async def request_commit(tx: Transaction) -> dict[str, Any]:
182
+ page = await tx.scan_entries({"conversationId": conversation_id}, 1)
183
+ cutoff: EntryId | None = page["items"][0]["id"] if page["items"] else None
184
+ for entry in entries:
185
+ cutoff = (
186
+ await tx.append_entry(
187
+ SystemEntry, conversation_id, cast(EntryDraft, entry)
188
+ )
189
+ )["id"]
190
+ if cutoff is None:
191
+ raise RuntimeError(f"Conversation {conversation_id} has no entries to send")
192
+ # Checked in this commit, so a compaction admitted during preparation counts.
193
+ live = await tx.doc(LiveDoc, conversation_id)
194
+ if threshold == "background" and live.get("compactions") is None:
195
+ await create_compaction(tx, conversation_id, {"reason": "threshold"})
196
+ checkpoint_value: dict[str, Any] = {
197
+ "phase": "request",
198
+ "attempt": attempt,
199
+ **_compact_fields(compacted),
200
+ "model": ref,
201
+ "thinkingLevel": agent.thinking_level,
202
+ "streamOptions": request_stream_options(settings.stream),
203
+ "cutoff": cutoff,
204
+ }
205
+ return {"status": "running", "checkpoint": checkpoint_value}
206
+
207
+ await runtime.commit(request_commit, context)
208
+
209
+
210
+ async def _request_phase(task: TaskRecord, runtime: Any, context: Context) -> None:
211
+ checkpoint = task_checkpoint(task)
212
+ attempt = int(checkpoint["attempt"])
213
+ compacted = checkpoint.get("compacted")
214
+ ref = cast(ModelRef, checkpoint["model"])
215
+ thinking_level = cast(str, checkpoint["thinkingLevel"])
216
+ stream_options = cast(dict[str, Any], checkpoint["streamOptions"])
217
+ cutoff = cast(EntryId, checkpoint["cutoff"])
218
+ conversation_id = runtime.conversation_id
219
+
220
+ async def start(tx: Transaction) -> None:
221
+ live = await tx.doc(LiveDoc, conversation_id)
222
+ await convert_partial(tx, live, conversation_id)
223
+ live["generation"] = {"attempt": attempt}
224
+
225
+ await runtime.commit(start, context)
226
+ model = runtime.models.get_model(ref["provider"], ref["modelId"])
227
+ if model is None:
228
+ await fail_no_model(runtime, ref, context)
229
+ return
230
+ view = await runtime.context(conversation_id, context, {"at": cutoff})
231
+ messages = list(view["messages"])
232
+
233
+ async def before_request(handler: Callable[..., Any]) -> None:
234
+ nonlocal messages
235
+ replaced = await call_hook(handler, {"messages": messages}, runtime, context)
236
+ if replaced is not None:
237
+ value = replaced.get("messages") if isinstance(replaced, Mapping) else None
238
+ if value is not None:
239
+ messages = list(value)
240
+
241
+ await runtime.hooks.each("beforeRequest", before_request)
242
+ options: dict[str, Any] = {
243
+ **stream_options,
244
+ "signal": runtime.signal,
245
+ "sessionId": await ensure_provider_session_id(runtime, context),
246
+ }
247
+ if thinking_level != "off":
248
+ options["reasoning"] = thinking_level
249
+ message = await stream_response(runtime, model, messages, options, attempt, context)
250
+ request = {
251
+ "attempt": attempt,
252
+ "compacted": compacted,
253
+ "model": ref,
254
+ "cutoff": cutoff,
255
+ "messages": list(view["messages"]),
256
+ }
257
+ await classify(runtime, request, message, context)
258
+
259
+
260
+ async def _retry_phase(task: TaskRecord, runtime: Any, context: Context) -> None:
261
+ checkpoint = task_checkpoint(task)
262
+ attempt = int(checkpoint["attempt"])
263
+ compacted = checkpoint.get("compacted")
264
+ await runtime.sleep(int(checkpoint["until"]), context)
265
+ conversation_id = runtime.conversation_id
266
+
267
+ async def retry(tx: Transaction) -> dict[str, Any]:
268
+ live = await tx.doc(LiveDoc, conversation_id)
269
+ live["generation"] = {"attempt": attempt + 1}
270
+ return {
271
+ "status": "running",
272
+ "checkpoint": _prepare(attempt + 1, **_compact_fields(compacted)),
273
+ }
274
+
275
+ await runtime.commit(retry, context)
276
+
277
+
278
+ async def _poll_phase(task: TaskRecord, runtime: Any, context: Context) -> None:
279
+ checkpoint = task_checkpoint(task)
280
+ ref = cast(ModelRef, checkpoint["model"])
281
+ model = runtime.models.get_model(ref["provider"], ref["modelId"])
282
+ if model is None:
283
+ await fail_no_model(runtime, ref, context)
284
+ return
285
+ await runtime.sleep(int(checkpoint["pollAt"]), context)
286
+ message = await runtime.models.fetch_deferred(
287
+ model, cast(JsonObject, checkpoint["handle"]), {"signal": runtime.signal}
288
+ )
289
+ await classify(
290
+ runtime,
291
+ {
292
+ "attempt": int(checkpoint["attempt"]),
293
+ "compacted": checkpoint.get("compacted"),
294
+ "model": ref,
295
+ "cutoff": checkpoint["cutoff"],
296
+ "pollAt": int(checkpoint["pollAt"]),
297
+ },
298
+ message,
299
+ context,
300
+ )
301
+
302
+
303
+ async def _tools_phase(task: TaskRecord, runtime: Any, context: Context) -> None:
304
+ checkpoint = cast(ToolsCheckpoint, task_checkpoint(task))
305
+ assistant = checkpoint["assistant"]
306
+ started = list(checkpoint["tools"])
307
+ pending = list(checkpoint["pending"])
308
+ if not pending:
309
+ await finish_tool_round(runtime, assistant, started, context)
310
+ return
311
+ # Sequential round: start the next call and wait for it.
312
+ call_id, rest = pending[0], pending[1:]
313
+
314
+ async def start(tx: Transaction) -> dict[str, Any]:
315
+ live = await tx.doc(LiveDoc, runtime.conversation_id)
316
+ task_id = await create_tool_task(tx, runtime, assistant, call_id)
317
+ for slot in live.get("tools") or ():
318
+ if slot.get("callId") == call_id and slot.get("taskId") is None:
319
+ slot["taskId"] = task_id
320
+ break
321
+ checkpoint_value: dict[str, Any] = {
322
+ "phase": "tools",
323
+ "assistant": assistant,
324
+ "tools": [*started, task_id],
325
+ "pending": rest,
326
+ }
327
+ return {"status": "waiting", "checkpoint": checkpoint_value, "on": [task_id], "policy": "allSettled"}
328
+
329
+ await runtime.commit(start, context)
330
+
331
+
332
+ async def _abort(task: TaskRecord, runtime: Any, context: Context) -> None:
333
+ checkpoint = task_checkpoint(task)
334
+ if checkpoint["phase"] == "poll":
335
+ ref = cast(ModelRef, checkpoint["model"])
336
+ model = runtime.models.get_model(ref["provider"], ref["modelId"])
337
+ if model is not None:
338
+ try:
339
+ await runtime.models.cancel_deferred(
340
+ model, cast(JsonObject, checkpoint["handle"]), {"signal": runtime.signal}
341
+ )
342
+ except BaseException as error:
343
+ runtime.report(error)
344
+ conversation_id = runtime.conversation_id
345
+ # Runs after the round's tool tasks are terminal; calls never started get ``aborted`` results.
346
+ unstarted = (
347
+ await read_calls(runtime, checkpoint["assistant"], checkpoint["pending"], context)
348
+ if checkpoint["phase"] == "tools"
349
+ else []
350
+ )
351
+
352
+ async def stop(tx: Transaction) -> dict[str, Any]:
353
+ live = await tx.doc(LiveDoc, conversation_id)
354
+ await convert_partial(tx, live, conversation_id)
355
+ for call in unstarted:
356
+ message = f"Tool {call['name']} was aborted"
357
+ await append_tool_result(
358
+ tx,
359
+ conversation_id,
360
+ call,
361
+ harness_error("aborted", message),
362
+ {"timestamp": runtime.now()},
363
+ )
364
+ end_run(tx, live, runtime.task_id, {"status": "unanswered", "reason": "aborted"})
365
+ return {"status": "terminal", "outcome": {"status": "aborted"}}
366
+
367
+ await runtime.commit(stop, context)
368
+
369
+
370
+ GenerationTask = define_task(
371
+ {
372
+ "name": "pi.generation",
373
+ "version": 1,
374
+ "initial": lambda _input: {"phase": "prepare", "attempt": 1},
375
+ "phases": {
376
+ "prepare": _prepare_phase,
377
+ "request": _request_phase,
378
+ "retry": _retry_phase,
379
+ "poll": _poll_phase,
380
+ "tools": _tools_phase,
381
+ },
382
+ "abort": _abort,
383
+ }
384
+ )
385
+
386
+
387
+ def _delay(seconds: float) -> Awaitable[None]:
388
+ return asyncio.sleep(seconds)
389
+
390
+
391
+ class _Flusher:
392
+ """One throttled partial publisher: at most one commit in flight and one commit per interval."""
393
+
394
+ def __init__(self, runtime: Any, interval_ms: int, attempt: int, context: Context) -> None:
395
+ self._runtime = runtime
396
+ self._interval = max(0, interval_ms) / 1000.0
397
+ self._attempt = attempt
398
+ self._context = context
399
+ self._pending: dict[str, Any] | None = None
400
+ self._stopped = False
401
+ self._wake = asyncio.Event()
402
+ self._stop = asyncio.Event()
403
+ self._task: asyncio.Task[None] | None = None
404
+
405
+ def start(self) -> None:
406
+ self._task = asyncio.ensure_future(self._run())
407
+ self._task.add_done_callback(
408
+ lambda done: done.exception() if not done.cancelled() else None
409
+ )
410
+
411
+ def push(self, partial: dict[str, Any]) -> None:
412
+ self._pending = partial
413
+ self._wake.set()
414
+
415
+ async def stop(self) -> None:
416
+ self._stopped = True
417
+ self._stop.set()
418
+ self._wake.set()
419
+ if self._task is not None:
420
+ await self._task
421
+
422
+ async def _run(self) -> None:
423
+ while True:
424
+ await self._wake.wait()
425
+ self._wake.clear()
426
+ if self._stopped:
427
+ return
428
+ if self._interval > 0:
429
+ # A stop interrupts the throttle instead of waiting it out before classification.
430
+ try:
431
+ await asyncio.wait_for(self._stop.wait(), self._interval)
432
+ except TimeoutError:
433
+ pass
434
+ if self._stopped:
435
+ return
436
+ partial = self._pending
437
+ self._pending = None
438
+ if partial is None:
439
+ continue
440
+ try:
441
+ message = copy_json(partial)
442
+ await self._commit(message)
443
+ except BaseException as error:
444
+ if not self._runtime.signal.aborted:
445
+ self._runtime.report(error)
446
+
447
+ async def _commit(self, message: JsonValue) -> None:
448
+ conversation_id = self._runtime.conversation_id
449
+ attempt = self._attempt
450
+
451
+ async def flush(tx: Transaction) -> None:
452
+ live = await tx.doc(LiveDoc, conversation_id)
453
+ if "generation" not in live:
454
+ live["generation"] = {"attempt": attempt}
455
+ generation = live["generation"]
456
+ assign_json(generation, "message", message)
457
+
458
+ await self._runtime.commit(flush, self._context)
459
+
460
+
461
+ async def stream_response(
462
+ runtime: Any,
463
+ model: Any,
464
+ messages: Sequence[Mapping[str, Any]],
465
+ options: dict[str, Any],
466
+ attempt: int,
467
+ context: Context,
468
+ ) -> dict[str, Any]:
469
+ """Stream one request and return the terminal message, publishing throttled partials."""
470
+
471
+ interval = int(runtime.settings.progress.get("partial_interval_ms", 100))
472
+ flusher = _Flusher(runtime, interval, attempt, context)
473
+ flusher.start()
474
+ try:
475
+ stream = runtime.models.stream_simple(model, {"messages": list(messages)}, options)
476
+ async for event in stream:
477
+ kind = event.get("type")
478
+ if kind in ("done", "error"):
479
+ continue
480
+ partial = event.get("partial")
481
+ if not isinstance(partial, Mapping):
482
+ continue
483
+ content = partial.get("content")
484
+ if not content:
485
+ continue
486
+ flusher.push(cast(dict[str, Any], dict(partial)))
487
+ return await stream.result()
488
+ finally:
489
+ await flusher.stop()
490
+
491
+
492
+ async def classify(
493
+ runtime: Any,
494
+ request: Mapping[str, Any],
495
+ message: Mapping[str, Any],
496
+ context: Context,
497
+ ) -> None:
498
+ """Classify a terminal provider message in one commit that also clears the partial."""
499
+
500
+ runtime.signal.raise_if_aborted()
501
+ conversation_id = runtime.conversation_id
502
+ attempt = int(request["attempt"])
503
+ compacted = request.get("compacted")
504
+ ref = cast(ModelRef, request["model"])
505
+ cutoff = cast(EntryId, request["cutoff"])
506
+ if message.get("stopReason") == "deferred" and message.get("deferred") is not None:
507
+ handle = cast(dict[str, Any], copy_json(message["deferred"]))
508
+ previous = request.get("pollAt")
509
+ poll_at = runtime.now() + int(handle.get("pollAfterMs", DEFAULT_POLL_AFTER_MS))
510
+ if previous is not None:
511
+ poll_at = max(poll_at, int(previous) + 1)
512
+
513
+ async def poll(tx: Transaction) -> dict[str, Any]:
514
+ live = await tx.doc(LiveDoc, conversation_id)
515
+ live["generation"] = {"attempt": attempt, "deferred": {"pollAt": poll_at}}
516
+ return {
517
+ "status": "running",
518
+ "checkpoint": {
519
+ "phase": "poll",
520
+ "attempt": attempt,
521
+ **_compact_fields(compacted),
522
+ "model": ref,
523
+ "cutoff": cutoff,
524
+ "handle": handle,
525
+ "pollAt": poll_at,
526
+ },
527
+ }
528
+
529
+ await runtime.commit(poll, context)
530
+ return
531
+
532
+ async def after_response(handler: Callable[..., Any]) -> None:
533
+ await call_hook(handler, message, runtime, context)
534
+
535
+ await runtime.hooks.each("afterResponse", after_response)
536
+ calls = [
537
+ content
538
+ for content in cast(Sequence[Mapping[str, Any]], message.get("content") or ())
539
+ if content.get("type") == "toolCall"
540
+ ]
541
+ if message.get("stopReason") == "toolUse" and calls:
542
+ await start_tool_round(runtime, request, message, calls, context)
543
+ return
544
+ if message.get("stopReason") in ("stop", "length", "toolUse"):
545
+ await answer(runtime, message, context)
546
+ return
547
+
548
+ settings = runtime.settings
549
+ overflow = message.get("stopReason") == "error" and is_context_overflow(message)
550
+ if overflow and compacted is None and settings.compaction.get("enabled"):
551
+ policy = settings.compaction
552
+ view = await runtime.context(conversation_id, context, {"at": cutoff})
553
+ if select_cut(view, int(policy.get("keep_recent_tokens", 0))) is not None:
554
+ text = str(message.get("errorMessage") or "Context overflow")
555
+
556
+ async def compact(tx: Transaction) -> dict[str, Any]:
557
+ live = await tx.doc(LiveDoc, conversation_id)
558
+ await append_assistant(tx, conversation_id, message)
559
+ del live["generation"]
560
+ child = await create_compaction(
561
+ tx, conversation_id, {"reason": "overflow"}, runtime.task_id
562
+ )
563
+ checkpoint_value: dict[str, Any] = {
564
+ "phase": "prepare",
565
+ "attempt": attempt,
566
+ **_compact_fields(compacted),
567
+ "compacted": child,
568
+ "overflow": text,
569
+ }
570
+ return {
571
+ "status": "waiting",
572
+ "checkpoint": checkpoint_value,
573
+ "on": [child],
574
+ "policy": "allSettled",
575
+ }
576
+
577
+ await runtime.commit(compact, context)
578
+ return
579
+ policy = settings.retry
580
+ retry = (
581
+ message.get("stopReason") == "error"
582
+ and not overflow
583
+ and is_retryable_assistant_error(message)
584
+ and bool(policy.get("enabled", True))
585
+ and attempt <= int(policy.get("max_retries", 3))
586
+ )
587
+ until = runtime.now() + retry_delay_ms(policy, attempt) if retry else 0
588
+
589
+ async def settle(tx: Transaction) -> dict[str, Any]:
590
+ live = await tx.doc(LiveDoc, conversation_id)
591
+ await append_assistant(tx, conversation_id, message)
592
+ if retry:
593
+ error_text = str(message.get("errorMessage") or "")
594
+ live["generation"] = {"attempt": attempt, "retry": {"at": until, "error": error_text}}
595
+ return {
596
+ "status": "running",
597
+ "checkpoint": {
598
+ "phase": "retry",
599
+ "attempt": attempt,
600
+ **_compact_fields(compacted),
601
+ "until": until,
602
+ },
603
+ }
604
+ text = str(
605
+ message.get("errorMessage")
606
+ or f"Model response ended with stop reason {message.get('stopReason')}"
607
+ )
608
+ end_run(
609
+ tx,
610
+ live,
611
+ runtime.task_id,
612
+ {"status": "unanswered", "reason": "model_error", "detail": text},
613
+ )
614
+ return {
615
+ "status": "terminal",
616
+ "outcome": {
617
+ "status": "failed",
618
+ "error": {"message": text, "detail": {"reason": "model_error"}},
619
+ },
620
+ }
621
+
622
+ await runtime.commit(settle, context)
623
+
624
+
625
+ async def answer(runtime: Any, message: Mapping[str, Any], context: Context) -> None:
626
+ """Commit a final answer, apply the final boundary, and hand the run to its successor."""
627
+
628
+ continuation: Any = None
629
+
630
+ async def on_yield(handler: Callable[..., Any]) -> None:
631
+ nonlocal continuation
632
+ if continuation is not None:
633
+ return
634
+ result = await call_hook(handler, message, runtime, context)
635
+ if isinstance(result, Mapping):
636
+ continuation = result.get("continue")
637
+
638
+ await runtime.hooks.each("onYield", on_yield)
639
+ conversation_id = runtime.conversation_id
640
+
641
+ async def finish(tx: Transaction) -> dict[str, Any]:
642
+ settings = runtime.settings
643
+ boundary = await prepare_boundary(
644
+ tx,
645
+ conversation_id,
646
+ QueueModes(settings.steering_mode, settings.follow_up_mode),
647
+ )
648
+ live = await tx.doc(LiveDoc, conversation_id)
649
+ entry = await append_assistant(tx, conversation_id, message)
650
+ result = {
651
+ "status": "terminal",
652
+ "outcome": {"status": "completed", "result": {"entryId": entry["id"]}},
653
+ }
654
+ placed = await apply_boundary(tx, boundary, "final", runtime.now())
655
+ if continuation is not None and not placed.users and not placed.reset:
656
+ user = {"role": "user", "content": continuation, "timestamp": runtime.now()}
657
+ await tx.append_entry(
658
+ UserEntry, conversation_id, cast(EntryDraft, {"model": [user]})
659
+ )
660
+ del live["generation"]
661
+ hand_over(
662
+ live,
663
+ runtime.task_id,
664
+ await create_generation(tx, conversation_id, task_for(runtime)),
665
+ )
666
+ return result
667
+ end_run(
668
+ tx,
669
+ live,
670
+ runtime.task_id,
671
+ {"status": "done", "answer": entry["id"]},
672
+ )
673
+ if placed.users:
674
+ await start_run(tx, conversation_id, live, placed.users, task_for(runtime))
675
+ return result
676
+
677
+ await runtime.commit(finish, context)
678
+
679
+
680
+ async def read_calls(
681
+ runtime: Any,
682
+ assistant: EntryId,
683
+ call_ids: Sequence[str],
684
+ context: Context,
685
+ ) -> list[dict[str, Any]]:
686
+ """The calls ``call_ids`` of the assistant entry, in the given order."""
687
+
688
+ entry = await runtime.entry(AssistantEntry, assistant, context)
689
+ calls = entry_tool_calls(entry)
690
+ return [
691
+ dict(call)
692
+ for call_id in call_ids
693
+ for call in calls
694
+ if call.get("id") == call_id
695
+ ]
696
+
697
+
698
+ def _is_sequential(tools: Sequence[Any], name: str) -> bool:
699
+ tool = next((candidate for candidate in tools if candidate.name == name), None)
700
+ return tool is not None and tool.execution_mode == "sequential"
701
+
702
+
703
+ async def create_tool_task(
704
+ tx: Transaction, runtime: Any, assistant: EntryId, call_id: str
705
+ ) -> TaskId:
706
+ """A tool task for ``call_id``, owned by the generation."""
707
+
708
+ return await tx.create_task(
709
+ ToolTask,
710
+ {"kind": "model", "assistant": assistant, "callId": call_id},
711
+ {"ownership": {"kind": "task", "taskId": runtime.task_id}},
712
+ )
713
+
714
+
715
+ async def start_tool_round(
716
+ runtime: Any,
717
+ request: Mapping[str, Any],
718
+ message: Mapping[str, Any],
719
+ calls: Sequence[Mapping[str, Any]],
720
+ context: Context,
721
+ ) -> None:
722
+ """Append the tool-calling answer and start its tool round in one commit.
723
+
724
+ A call to a tool the request did not offer gets its ``tool_unavailable`` result here; every other
725
+ call gets a tool task owned by the generation, only the first one now when the round is
726
+ sequential. The generation then waits for them in its ``tools`` phase, keeping the run.
727
+ """
728
+
729
+ conversation_id = runtime.conversation_id
730
+ messages = request.get("messages")
731
+ if messages is None:
732
+ view = await runtime.context(conversation_id, context, {"at": request["cutoff"]})
733
+ messages = view["messages"]
734
+ offered = {tool["name"] for tool in current_tools(cast(Sequence[Mapping[str, Any]], messages))}
735
+ # Read as the round starts; a tool is resolved as its tool task resolves it.
736
+ agent = await runtime.agent(context)
737
+ sequential = runtime.settings.tool_execution == "sequential" or any(
738
+ call["name"] in offered and _is_sequential(agent.tools, call["name"]) for call in calls
739
+ )
740
+
741
+ async def start(tx: Transaction) -> dict[str, Any]:
742
+ live = await tx.doc(LiveDoc, conversation_id)
743
+ entry = await append_assistant(tx, conversation_id, message)
744
+ slots: list[dict[str, Any]] = []
745
+ tool_tasks: list[TaskId] = []
746
+ pending: list[str] = []
747
+ for call in calls:
748
+ if call["name"] not in offered:
749
+ unavailable = harness_error("tool_unavailable", f"Tool {call['name']} is not available")
750
+ result = await append_tool_result(
751
+ tx,
752
+ conversation_id,
753
+ call,
754
+ unavailable,
755
+ {"timestamp": runtime.now()},
756
+ )
757
+ slots.append(
758
+ {
759
+ "callId": call["id"],
760
+ "name": call["name"],
761
+ "status": "done",
762
+ "entry": result["id"],
763
+ }
764
+ )
765
+ continue
766
+ if sequential and tool_tasks:
767
+ pending.append(call["id"])
768
+ slots.append({"callId": call["id"], "name": call["name"], "status": "pending"})
769
+ continue
770
+ task_id = await create_tool_task(tx, runtime, entry["id"], call["id"])
771
+ tool_tasks.append(task_id)
772
+ slots.append(
773
+ {
774
+ "callId": call["id"],
775
+ "name": call["name"],
776
+ "taskId": task_id,
777
+ "status": "pending",
778
+ }
779
+ )
780
+ if "generation" in live:
781
+ del live["generation"]
782
+ live["tools"] = slots
783
+ checkpoint_value: dict[str, Any] = {
784
+ "phase": "tools",
785
+ "assistant": entry["id"],
786
+ "tools": tool_tasks,
787
+ "pending": pending,
788
+ }
789
+ return {
790
+ "status": "waiting",
791
+ "checkpoint": checkpoint_value,
792
+ "on": tool_tasks,
793
+ "policy": "allSettled",
794
+ }
795
+
796
+ await runtime.commit(start, context)
797
+
798
+
799
+ async def finish_tool_round(
800
+ runtime: Any,
801
+ assistant: EntryId,
802
+ tools: Sequence[TaskId],
803
+ context: Context,
804
+ ) -> None:
805
+ """The round's tools are terminal: apply their controls, then end the run or hand it on."""
806
+
807
+ conversation_id = runtime.conversation_id
808
+ controls: dict[TaskId, dict[str, Any] | None] = {}
809
+ outcomes = await runtime.outcomes(tools, context)
810
+ for task_id, outcome in zip(tools, outcomes, strict=False):
811
+ control = None
812
+ if outcome.get("status") == "completed":
813
+ result = outcome.get("result")
814
+ if isinstance(result, Mapping) and result.get("kind") != "nested":
815
+ raw = result.get("control")
816
+ control = cast(dict[str, Any], raw) if isinstance(raw, Mapping) else None
817
+ controls[task_id] = control
818
+ state = await runtime.snapshot(LiveDoc, conversation_id, context)
819
+ slots = [dict(slot) for slot in (state or {}).get("tools") or ()]
820
+ results = [slot["entry"] for slot in slots if slot.get("entry") is not None]
821
+
822
+ async def after_tools(handler: Callable[..., Any]) -> None:
823
+ await call_hook(handler, assistant, results, runtime, context)
824
+
825
+ await runtime.hooks.each("afterTools", after_tools)
826
+ # Every call of the round, including those answered without a task, must ask to terminate.
827
+ terminate = bool(slots) and all(
828
+ slot.get("taskId") is not None and (controls.get(slot["taskId"]) or {}).get("terminate") is True
829
+ for slot in slots
830
+ )
831
+ added = [
832
+ name
833
+ for control in controls.values()
834
+ for name in ((control or {}).get("addTools") or ())
835
+ ]
836
+ # The last handoff in call order wins.
837
+ handoff: str | None = None
838
+ for control in controls.values():
839
+ if control is not None and control.get("handoff") is not None:
840
+ handoff = str(control["handoff"])
841
+
842
+ async def finish(tx: Transaction) -> dict[str, Any]:
843
+ boundary = await prepare_boundary(
844
+ tx,
845
+ conversation_id,
846
+ QueueModes(runtime.settings.steering_mode, runtime.settings.follow_up_mode),
847
+ )
848
+ if added:
849
+ await add_tools(tx, conversation_id, added)
850
+ live = await tx.doc(LiveDoc, conversation_id)
851
+ now = runtime.now()
852
+ if terminate or handoff is not None:
853
+ if handoff is not None:
854
+ message = {"role": "user", "content": handoff, "timestamp": now}
855
+ entry = await tx.append_entry(
856
+ ResetEntry,
857
+ conversation_id,
858
+ cast(EntryDraft, {"head": "self", "model": [message]}),
859
+ )
860
+ boundary.head = entry["id"]
861
+ placed = await apply_boundary(tx, boundary, "final", now)
862
+ end_run(tx, live, runtime.task_id, {"status": "done", "answer": assistant})
863
+ if placed.users:
864
+ await start_run(tx, conversation_id, live, placed.users, task_for(runtime))
865
+ else:
866
+ placed = await apply_boundary(tx, boundary, "postTools", now)
867
+ if placed.reset:
868
+ # The queued reset cut the run's context before an answer.
869
+ end_run(tx, live, runtime.task_id, {"status": "unanswered", "reason": "reset"})
870
+ if placed.users:
871
+ await start_run(tx, conversation_id, live, placed.users, task_for(runtime))
872
+ else:
873
+ for key in ("tools", "nestedTools"):
874
+ if key in live:
875
+ del live[key]
876
+ run = live.get("run")
877
+ if run is not None and run["taskId"] == runtime.task_id:
878
+ run["inputs"].extend(placed.users)
879
+ hand_over(
880
+ live,
881
+ runtime.task_id,
882
+ await create_generation(tx, conversation_id, task_for(runtime)),
883
+ )
884
+ return {
885
+ "status": "terminal",
886
+ "outcome": {"status": "completed", "result": {"entryId": assistant}},
887
+ }
888
+
889
+ await runtime.commit(finish, context)
890
+
891
+
892
+ def task_for(runtime: Any) -> Any:
893
+ """The registry's generation task, the definition a successor run uses."""
894
+
895
+ task = runtime.registry.task("pi.generation")
896
+ if task is None:
897
+ raise RuntimeError("Registry lacks the pi.generation task")
898
+ return task
899
+
900
+
901
+ async def fail_model_error(runtime: Any, text: str, context: Context) -> None:
902
+ conversation_id = runtime.conversation_id
903
+
904
+ async def fail(tx: Transaction) -> dict[str, Any]:
905
+ live = await tx.doc(LiveDoc, conversation_id)
906
+ end_run(
907
+ tx,
908
+ live,
909
+ runtime.task_id,
910
+ {"status": "unanswered", "reason": "model_error", "detail": text},
911
+ )
912
+ return {
913
+ "status": "terminal",
914
+ "outcome": {
915
+ "status": "failed",
916
+ "error": {"message": text, "detail": {"reason": "model_error"}},
917
+ },
918
+ }
919
+
920
+ await runtime.commit(fail, context)
921
+
922
+
923
+ async def fail_no_model(runtime: Any, ref: ModelRef | None, context: Context) -> None:
924
+ message = (
925
+ "No model is configured"
926
+ if ref is None
927
+ else f"Model {ref['provider']}/{ref['modelId']} is not available"
928
+ )
929
+ conversation_id = runtime.conversation_id
930
+
931
+ async def fail(tx: Transaction) -> dict[str, Any]:
932
+ live = await tx.doc(LiveDoc, conversation_id)
933
+ end_run(tx, live, runtime.task_id, {"status": "unanswered", "reason": "no_model"})
934
+ return {
935
+ "status": "terminal",
936
+ "outcome": {
937
+ "status": "failed",
938
+ "error": {"message": message, "detail": {"reason": "no_model"}},
939
+ },
940
+ }
941
+
942
+ await runtime.commit(fail, context)
943
+
944
+
945
+ def compaction_failure(outcome: Mapping[str, Any]) -> str:
946
+ status = outcome.get("status")
947
+ if status in ("failed", "faulted"):
948
+ return f"Compaction failed: {cast(Mapping[str, Any], outcome.get('error') or {}).get('message', '')}"
949
+ if status == "orphaned":
950
+ return f"Compaction failed: {outcome.get('reason', '')}"
951
+ return "Compaction was aborted"
952
+
953
+
954
+ def threshold_compaction(
955
+ view: ContextView,
956
+ planned: Sequence[Mapping[str, Any]],
957
+ context_window: int,
958
+ policy: Mapping[str, Any],
959
+ ) -> str | None:
960
+ """Which threshold compaction preparation starts before its request (spec section 8.3).
961
+
962
+ ``blocking`` above ``contextWindow - reserveTokens``, ``background`` above the background
963
+ threshold, and only when range selection finds a cut. The caller starts a background one only
964
+ while no compaction is listed.
965
+ """
966
+
967
+ if not policy.get("enabled") or context_window <= 0:
968
+ return None
969
+ planned_messages = [
970
+ message
971
+ for entry in planned
972
+ for message in cast(Sequence[Mapping[str, Any]], entry.get("model") or ())
973
+ ]
974
+ tokens = estimate_context(view, planned_messages)
975
+ blocking = context_window - int(policy.get("reserve_tokens", 0))
976
+ background = blocking - int(policy.get("background_tokens", 0))
977
+ over = (
978
+ "blocking"
979
+ if tokens > blocking
980
+ else "background"
981
+ if int(policy.get("background_tokens", 0)) > 0 and tokens > background
982
+ else None
983
+ )
984
+ if over is None or select_cut(view, int(policy.get("keep_recent_tokens", 0))) is None:
985
+ return None
986
+ return over
987
+
988
+
989
+ async def convert_partial(
990
+ tx: Transaction, live: Any, conversation_id: ConversationId
991
+ ) -> None:
992
+ """Append a committed partial as an aborted assistant entry; the caller replaces or removes it."""
993
+
994
+ generation = live.get("generation")
995
+ if generation is None:
996
+ return
997
+ partial = generation.get("message")
998
+ if partial is None:
999
+ return
1000
+ message = cast(dict[str, Any], copy_json(_plain(partial)))
1001
+ message["stopReason"] = "aborted"
1002
+ await append_assistant(tx, conversation_id, message)
1003
+
1004
+
1005
+ async def append_assistant(
1006
+ tx: Transaction, conversation_id: ConversationId, message: Mapping[str, Any]
1007
+ ) -> Any:
1008
+ """Append a provider result and add its usage to ``pi.usage`` in the same commit."""
1009
+
1010
+ provider = str(message.get("provider") or "")
1011
+ model = str(message.get("model") or "")
1012
+ await record_usage(
1013
+ tx,
1014
+ conversation_id,
1015
+ "models",
1016
+ f"{provider}/{model}",
1017
+ cast(Mapping[str, Any], message.get("usage") or {}),
1018
+ )
1019
+ return await tx.append_entry(
1020
+ AssistantEntry,
1021
+ conversation_id,
1022
+ cast(EntryDraft, {"model": [cast(MessageRecord, copy_json(message))]}),
1023
+ )
1024
+
1025
+
1026
+ async def create_generation(
1027
+ tx: Transaction, conversation_id: ConversationId, task: Any = GenerationTask
1028
+ ) -> TaskId:
1029
+ """A generation owned by its conversation."""
1030
+
1031
+ return await tx.create_task(
1032
+ task, {}, {"ownership": {"kind": "conversation"}, "conversationId": conversation_id}
1033
+ )
1034
+
1035
+
1036
+ def hand_over(live: Any, from_id: TaskId, to_id: TaskId) -> None:
1037
+ """Hand run control from ``from_id`` to ``to_id``; the run's inputs move with it."""
1038
+
1039
+ run = live.get("run")
1040
+ if run is not None and run["taskId"] == from_id:
1041
+ run["taskId"] = to_id
1042
+
1043
+
1044
+ # ── Partial writes ──────────────────────────────────────────────────────────
1045
+
1046
+
1047
+ def _plain(value: Any) -> Any:
1048
+ """A detached JSON value for a draft member."""
1049
+
1050
+ if isinstance(value, (DraftObject, DraftList)):
1051
+ return value.snapshot()
1052
+ return value
1053
+
1054
+
1055
+ def _is_record(value: Any) -> bool:
1056
+ return isinstance(value, (dict, DraftObject))
1057
+
1058
+
1059
+ def _is_array(value: Any) -> bool:
1060
+ return isinstance(value, (list, DraftList))
1061
+
1062
+
1063
+ __all__ = [
1064
+ "DEFAULT_POLL_AFTER_MS",
1065
+ "GenerationCheckpoint",
1066
+ "GenerationInput",
1067
+ "GenerationResult",
1068
+ "GenerationTask",
1069
+ "append_assistant",
1070
+ "classify",
1071
+ "compaction_failure",
1072
+ "convert_partial",
1073
+ "create_generation",
1074
+ "fail_model_error",
1075
+ "fail_no_model",
1076
+ "hand_over",
1077
+ "stream_response",
1078
+ "threshold_compaction",
1079
+ ]