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,673 @@
1
+ """Built-in ``pi.compaction`` task: select an old prefix, summarize it, and place a summary.
2
+
3
+ Port of v1.3.0 ``src/harness/compaction.ts``. Compaction replaces an old prefix of the model context
4
+ with a summary entry whose ``head`` is the first kept entry. A compaction the generation owns blocks
5
+ it and appends directly; a conversation-owned one places its summary through a write submission.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Mapping, Sequence
12
+ from typing import Any, NotRequired, TypedDict, cast
13
+
14
+ from ..context import Context
15
+ from ..entries import CompactionEntry
16
+ from ..ids import ConversationId, EntryId, TaskId
17
+ from ..json import JsonObject
18
+ from ..providers import (
19
+ calculate_context_tokens,
20
+ estimate_message_tokens,
21
+ is_retryable_assistant_error,
22
+ retry_delay_ms,
23
+ )
24
+ from ..session.transaction import Transaction
25
+ from ..tasks import define_task
26
+ from ..types import EntryDraft, MessageRecord, TaskRecord
27
+ from .context import order_tool_results
28
+ from .inbox import QueueModes
29
+ from .live import (
30
+ LiveDoc,
31
+ add_compaction_status,
32
+ clear_compaction_failure,
33
+ compaction_status,
34
+ fail_compaction_status,
35
+ remove_compaction_status,
36
+ start_generation,
37
+ )
38
+ from .provider import ensure_provider_session_id
39
+ from .submissions import admit_submission
40
+ from .types import CompactionReason, CompactionResult, ContextView, ModelRef
41
+ from .usage import record_usage
42
+ from .util import call_hook, request_stream_options, task_checkpoint
43
+
44
+ #: Longest tool result text a serialized summary source keeps.
45
+ TOOL_RESULT_MAX_CHARS = 2000
46
+
47
+ SUMMARY_PREFIX = (
48
+ "The conversation history before this point was compacted into the following summary:\n\n<summary>\n"
49
+ )
50
+ SUMMARY_SUFFIX = "\n</summary>"
51
+
52
+ SUMMARIZATION_SYSTEM_PROMPT = (
53
+ "You are a context summarization assistant. Your task is to read a conversation between a "
54
+ "user and an AI assistant, then produce a structured summary following the exact format "
55
+ "specified.\n\n"
56
+ "Do NOT continue the conversation. Do NOT respond to any questions in the conversation. "
57
+ "ONLY output the structured summary."
58
+ )
59
+
60
+ SUMMARIZATION_PROMPT = (
61
+ "The messages above are a conversation to summarize. Create a structured context "
62
+ "checkpoint summary that another LLM will use to continue the work. If the conversation "
63
+ "starts with an earlier summary, preserve its information and fold the newer messages "
64
+ "into it.\n\n"
65
+ """Use this EXACT format:
66
+
67
+ ## Goal
68
+ [What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
69
+
70
+ ## Constraints & Preferences
71
+ - [Any constraints, preferences, or requirements mentioned by user]
72
+ - [Or "(none)" if none were mentioned]
73
+
74
+ ## Progress
75
+ ### Done
76
+ - [x] [Completed tasks/changes]
77
+
78
+ ### In Progress
79
+ - [ ] [Current work]
80
+
81
+ ### Blocked
82
+ - [Issues preventing progress, if any]
83
+
84
+ ## Key Decisions
85
+ - **[Decision]**: [Brief rationale]
86
+
87
+ ## Next Steps
88
+ 1. [Ordered list of what should happen next]
89
+
90
+ ## Critical Context
91
+ - [Any data, examples, or references needed to continue]
92
+ - [Or "(none)" if not applicable]
93
+
94
+ Keep each section concise. Preserve exact file paths, function names, and error messages."""
95
+ )
96
+
97
+
98
+ class CompactionInput(TypedDict):
99
+ reason: CompactionReason
100
+ instructions: NotRequired[str]
101
+
102
+
103
+ class SummaryRequest(TypedDict):
104
+ """The pinned summarization request."""
105
+
106
+ attempt: int
107
+ model: ModelRef
108
+ thinkingLevel: str
109
+ streamOptions: JsonObject
110
+ maxTokens: int
111
+ tail: EntryId
112
+ firstKept: EntryId
113
+
114
+
115
+ class SelectCheckpoint(TypedDict):
116
+ phase: str
117
+
118
+
119
+ class SummarizeCheckpoint(SummaryRequest):
120
+ phase: str
121
+
122
+
123
+ class RetryCheckpoint(SummaryRequest):
124
+ phase: str
125
+ until: int
126
+
127
+
128
+ CompactionCheckpoint = SelectCheckpoint | SummarizeCheckpoint | RetryCheckpoint
129
+ """Checkpoint shapes the compaction phases read and write."""
130
+
131
+
132
+ async def _select_phase(task: TaskRecord, runtime: Any, context: Context) -> None:
133
+ conversation_id = runtime.conversation_id
134
+ agent = await runtime.agent(context)
135
+ settings = runtime.settings
136
+ ref = agent.model
137
+ model = None if ref is None else runtime.models.get_model(ref["provider"], ref["modelId"])
138
+ if ref is None or model is None:
139
+ await fail_no_model(runtime, ref, context)
140
+ return
141
+ policy = settings.compaction
142
+ view = await runtime.context(conversation_id, context)
143
+ cut = select_cut(view, int(policy.get("keep_recent_tokens", 0)))
144
+ if cut is None:
145
+ await complete(runtime, context)
146
+ return
147
+ first_kept = view["entries"][cut]["id"]
148
+ input = cast(CompactionInput, task["input"])
149
+ reason = input["reason"]
150
+ instructions = input.get("instructions")
151
+ compaction: dict[str, Any] = {
152
+ "reason": reason,
153
+ "entries": view["entries"][:cut],
154
+ "messages": summarized_messages(view, cut),
155
+ "firstKept": first_kept,
156
+ }
157
+ if instructions is not None:
158
+ compaction["instructions"] = instructions
159
+ decision: Any = None
160
+
161
+ async def before_compact(handler: Any) -> None:
162
+ nonlocal decision
163
+ result = await call_hook(handler, compaction, runtime, context)
164
+ if decision is None:
165
+ decision = result
166
+
167
+ await runtime.hooks.each("beforeCompact", before_compact)
168
+ if isinstance(decision, Mapping) and decision.get("decline") is True:
169
+ await complete(runtime, context)
170
+ return
171
+ if isinstance(decision, Mapping) and decision.get("summary") is not None:
172
+ await place(runtime, first_kept, str(decision["summary"]), context)
173
+ return
174
+ request: SummaryRequest = {
175
+ "attempt": 1,
176
+ "model": ref,
177
+ "thinkingLevel": agent.thinking_level,
178
+ "streamOptions": request_stream_options(settings.stream),
179
+ "maxTokens": min(
180
+ int(0.8 * int(policy.get("reserve_tokens", 0))),
181
+ model.max_tokens if model.max_tokens > 0 else 2**31 - 1,
182
+ ),
183
+ "tail": max((entry["id"] for entry in view["entries"]), default=first_kept),
184
+ "firstKept": first_kept,
185
+ }
186
+
187
+ async def start(tx: Transaction) -> dict[str, Any]:
188
+ return {"status": "running", "checkpoint": {"phase": "summarize", **request}}
189
+
190
+ await runtime.commit(start, context)
191
+
192
+
193
+ async def _summarize_phase(task: TaskRecord, runtime: Any, context: Context) -> None:
194
+ checkpoint = task_checkpoint(task)
195
+ request = {key: value for key, value in checkpoint.items() if key != "phase"}
196
+ ref = cast(ModelRef, request["model"])
197
+ thinking_level = cast(str, request["thinkingLevel"])
198
+ stream_options = cast(Mapping[str, Any], request["streamOptions"])
199
+ max_tokens = int(request["maxTokens"])
200
+ tail = cast(EntryId, request["tail"])
201
+ first_kept = cast(EntryId, request["firstKept"])
202
+ attempt = int(request["attempt"])
203
+ model = runtime.models.get_model(ref["provider"], ref["modelId"])
204
+ if model is None:
205
+ await fail_no_model(runtime, ref, context)
206
+ return
207
+ # The context at `tail` is immutable, so this is the range `select` chose.
208
+ view = await runtime.context(runtime.conversation_id, context, {"at": tail})
209
+ cut = next(
210
+ (index for index, entry in enumerate(view["entries"]) if entry["id"] == first_kept),
211
+ -1,
212
+ )
213
+ now = runtime.now()
214
+ input = cast(CompactionInput, task["input"])
215
+ messages: list[dict[str, Any]] = [
216
+ {"role": "system", "content": SUMMARIZATION_SYSTEM_PROMPT, "timestamp": now},
217
+ {
218
+ "role": "user",
219
+ "content": [
220
+ {
221
+ "type": "text",
222
+ "text": summary_prompt(summarized_messages(view, cut), input.get("instructions")),
223
+ }
224
+ ],
225
+ "timestamp": now,
226
+ },
227
+ ]
228
+ options: dict[str, Any] = {
229
+ **{key: value for key, value in stream_options.items() if key != "deferred"},
230
+ "cacheRetention": "none",
231
+ "maxTokens": max_tokens,
232
+ "signal": runtime.signal,
233
+ "sessionId": await ensure_provider_session_id(runtime, context),
234
+ }
235
+ if thinking_level != "off":
236
+ options["reasoning"] = thinking_level
237
+ message = await runtime.models.complete_simple(model, {"messages": messages}, options)
238
+ # An abort mark or close: the abort invocation or the reopened task handles the committed state.
239
+ if runtime.signal is not None:
240
+ runtime.signal.raise_if_aborted()
241
+ summary = summary_text(message)
242
+ policy = runtime.settings.retry
243
+ retry = (
244
+ message.get("stopReason") == "error"
245
+ and is_retryable_assistant_error(message)
246
+ and bool(policy.get("enabled", True))
247
+ and attempt <= int(policy.get("max_retries", 3))
248
+ )
249
+ until = runtime.now() + retry_delay_ms(policy, attempt) if retry else 0
250
+
251
+ async def settle(tx: Transaction) -> dict[str, Any]:
252
+ await record_usage(
253
+ tx,
254
+ runtime.conversation_id,
255
+ "models",
256
+ f"{message.get('provider')}/{message.get('model')}",
257
+ cast(Mapping[str, Any], message.get("usage") or {}),
258
+ )
259
+ live = await tx.doc(LiveDoc, runtime.conversation_id)
260
+ if summary is not None:
261
+ return await _place_summary(tx, runtime, task, live, first_kept, summary)
262
+ if retry:
263
+ status = compaction_status(live, runtime.task_id)
264
+ if status is not None:
265
+ status["retry"] = {"at": until, "error": str(message.get("errorMessage") or "")}
266
+ return {"status": "running", "checkpoint": {"phase": "retry", **request, "until": until}}
267
+ text = summary_failure(message)
268
+ fail_compaction_status(live, runtime.task_id, text)
269
+ return {
270
+ "status": "terminal",
271
+ "outcome": {"status": "failed", "error": {"message": text, "detail": {"reason": "model_error"}}},
272
+ }
273
+
274
+ await runtime.commit(settle, context)
275
+
276
+
277
+ async def _retry_phase(task: TaskRecord, runtime: Any, context: Context) -> None:
278
+ checkpoint = task_checkpoint(task)
279
+ until = int(checkpoint["until"])
280
+ request = {key: value for key, value in checkpoint.items() if key not in ("phase", "until")}
281
+ await runtime.sleep(until, context)
282
+ attempt = int(request["attempt"]) + 1
283
+
284
+ async def retry(tx: Transaction) -> dict[str, Any]:
285
+ live = await tx.doc(LiveDoc, runtime.conversation_id)
286
+ status = compaction_status(live, runtime.task_id)
287
+ if status is not None:
288
+ status["attempt"] = attempt
289
+ if "retry" in status:
290
+ del status["retry"]
291
+ return {"status": "running", "checkpoint": {"phase": "summarize", **request, "attempt": attempt}}
292
+
293
+ await runtime.commit(retry, context)
294
+
295
+
296
+ async def _abort(task: TaskRecord, runtime: Any, context: Context) -> None:
297
+ async def stop(tx: Transaction) -> dict[str, Any]:
298
+ remove_compaction_status(await tx.doc(LiveDoc, runtime.conversation_id), runtime.task_id)
299
+ return {"status": "terminal", "outcome": {"status": "aborted"}}
300
+
301
+ await runtime.commit(stop, context)
302
+
303
+
304
+ CompactionTask = define_task(
305
+ {
306
+ "name": "pi.compaction",
307
+ "version": 1,
308
+ "initial": lambda _input: {"phase": "select"},
309
+ "phases": {
310
+ "select": _select_phase,
311
+ "summarize": _summarize_phase,
312
+ "retry": _retry_phase,
313
+ },
314
+ "abort": _abort,
315
+ }
316
+ )
317
+
318
+
319
+ async def create_compaction(
320
+ tx: Transaction,
321
+ conversation_id: ConversationId,
322
+ input: CompactionInput,
323
+ owner: TaskId | None = None,
324
+ ) -> TaskId:
325
+ """Create a compaction task with its live status in this commit.
326
+
327
+ ``owner`` is the generation that waits for it (a blocking compaction); without one it is
328
+ conversation-owned, and ``background`` unless the reason is manual.
329
+ """
330
+
331
+ ownership: Any = (
332
+ {"kind": "conversation"} if owner is None else {"kind": "task", "taskId": owner}
333
+ )
334
+ background = owner is None and input["reason"] != "manual"
335
+ task_id = await tx.create_task(
336
+ CompactionTask,
337
+ cast(Any, dict(input)),
338
+ {
339
+ "ownership": ownership,
340
+ "conversationId": conversation_id,
341
+ "background": background,
342
+ },
343
+ )
344
+ add_compaction_status(
345
+ await tx.doc(LiveDoc, conversation_id),
346
+ {"taskId": task_id, "reason": input["reason"], "blocking": owner is not None, "attempt": 1},
347
+ )
348
+ return task_id
349
+
350
+
351
+ def select_cut(view: ContextView, keep_recent_tokens: int) -> int | None:
352
+ """Index in ``view.entries`` of the first entry a summary keeps (spec section 8.7).
353
+
354
+ Walks back from the tail until ``keep_recent_tokens`` are kept, then cuts at the first
355
+ candidate at or after that entry: an entry whose contribution starts with a user or assistant
356
+ message, never a tool result, and never a user entry that a result of the preceding
357
+ assistant's calls still follows.
358
+ """
359
+
360
+ contributions = view["contributions"]
361
+ start = 0 if view["head"] is None else 1
362
+ candidates = [index for index in range(start, len(contributions)) if _is_candidate(contributions, index)]
363
+ kept = 0
364
+ cut: int | None = None
365
+ for index in range(len(contributions) - 1, start - 1, -1):
366
+ for message in contributions[index]:
367
+ kept += estimate_message_tokens(message)
368
+ if kept < keep_recent_tokens:
369
+ continue
370
+ following = next((candidate for candidate in candidates if candidate >= index), None)
371
+ cut = following if following is not None else (candidates[-1] if candidates else None)
372
+ break
373
+ if cut is None:
374
+ return None
375
+ for index in range(start, cut):
376
+ if contributions[index]:
377
+ return cut
378
+ return None
379
+
380
+
381
+ def _is_candidate(contributions: Sequence[Sequence[MessageRecord]], index: int) -> bool:
382
+ first = contributions[index][0] if contributions[index] else None
383
+ if first is None:
384
+ return False
385
+ if first["role"] == "assistant":
386
+ return True
387
+ if first["role"] != "user":
388
+ return False
389
+ # A result of the preceding assistant's calls that follows this entry belongs before it.
390
+ calls: set[str] = set()
391
+ for before in range(index - 1, -1, -1):
392
+ assistant = next(
393
+ (message for message in reversed(contributions[before]) if message["role"] == "assistant"),
394
+ None,
395
+ )
396
+ if assistant is None:
397
+ continue
398
+ calls = {
399
+ cast(str, content["id"])
400
+ for content in cast(Sequence[Mapping[str, Any]], assistant["content"])
401
+ if content.get("type") == "toolCall"
402
+ }
403
+ break
404
+ if not calls:
405
+ return True
406
+ for after in range(index, len(contributions)):
407
+ for position, message in enumerate(contributions[after]):
408
+ if message["role"] == "assistant" and (after > index or position > 0):
409
+ return True
410
+ if message["role"] == "toolResult" and message.get("toolCallId") in calls:
411
+ return False
412
+ return True
413
+
414
+
415
+ def summarized_messages(view: ContextView, cut: int) -> list[MessageRecord]:
416
+ """Model messages of the entries before ``cut``: the head marker first, context order."""
417
+
418
+ return order_tool_results([message for messages in view["contributions"][:cut] for message in messages])
419
+
420
+
421
+ def estimate_context(view: ContextView, extra: Sequence[Mapping[str, Any]]) -> int:
422
+ """Size of a request over ``view`` followed by ``extra`` (spec section 8.3).
423
+
424
+ The usage of the newest assistant appended after the head marker, whose request included the
425
+ marker, plus estimates of the messages after it; without one, estimates of every message.
426
+ """
427
+
428
+ measured: Mapping[str, Any] | None = None
429
+ after = view["head"]["id"] if view["head"] is not None else -(2**31)
430
+ index = len(view["entries"]) - 1
431
+ while index >= 0 and measured is None:
432
+ if view["entries"][index]["id"] > after:
433
+ measured = next(
434
+ (
435
+ message
436
+ for message in reversed(view["contributions"][index])
437
+ if message["role"] == "assistant" and _context_tokens(message) > 0
438
+ ),
439
+ None,
440
+ )
441
+ index -= 1
442
+ from_index = (
443
+ 0
444
+ if measured is None
445
+ else next(
446
+ (
447
+ index
448
+ for index in range(len(view["messages"]) - 1, -1, -1)
449
+ if view["messages"][index] is measured
450
+ ),
451
+ -1,
452
+ )
453
+ + 1
454
+ )
455
+ tokens = 0 if measured is None else _context_tokens(measured)
456
+ for message in view["messages"][from_index:]:
457
+ tokens += estimate_message_tokens(message)
458
+ for planned in extra:
459
+ tokens += estimate_message_tokens(planned)
460
+ return tokens
461
+
462
+
463
+ def _context_tokens(message: Mapping[str, Any]) -> int:
464
+ return calculate_context_tokens(cast(Mapping[str, Any], message.get("usage") or {}))
465
+
466
+
467
+ def summary_text(message: Mapping[str, Any]) -> str | None:
468
+ """The summary of a clean ``stop`` with text and no tool call; anything else is not a summary."""
469
+
470
+ content = cast(Sequence[Mapping[str, Any]], message.get("content") or ())
471
+ if message.get("stopReason") != "stop" or any(item.get("type") == "toolCall" for item in content):
472
+ return None
473
+ text = "\n".join(
474
+ cast(str, item["text"]) for item in content if item.get("type") == "text"
475
+ ).strip()
476
+ return None if not text else text
477
+
478
+
479
+ def summary_failure(message: Mapping[str, Any]) -> str:
480
+ stop_reason = message.get("stopReason")
481
+ if stop_reason in ("error", "aborted"):
482
+ return f"Summarization failed: {message.get('errorMessage') or stop_reason}"
483
+ if stop_reason == "length":
484
+ return "Summarization hit the token limit; the summary is incomplete"
485
+ content = cast(Sequence[Mapping[str, Any]], message.get("content") or ())
486
+ if any(item.get("type") == "toolCall" for item in content):
487
+ return "Summarization attempted to call a tool"
488
+ return "Summarization produced no text"
489
+
490
+
491
+ def summary_prompt(messages: Sequence[Mapping[str, Any]], instructions: str | None) -> str:
492
+ """The summarizer's user message: the serialized conversation, the prompt, and any instructions."""
493
+
494
+ focus = "" if instructions is None else f"\n\nAdditional focus: {instructions}"
495
+ return (
496
+ f"<conversation>\n{serialize_conversation(messages)}\n</conversation>\n\n"
497
+ f"{SUMMARIZATION_PROMPT}{focus}"
498
+ )
499
+
500
+
501
+ def serialize_conversation(messages: Sequence[Mapping[str, Any]]) -> str:
502
+ """Messages as plain text, so the summarizer reads a transcript instead of continuing it.
503
+
504
+ System messages are omitted. Thinking is omitted too: providers return summaries of the real
505
+ reasoning, and replayed thinking is often not part of the live context, so including it can make
506
+ the request larger than the context it summarizes. Visible text, tool calls, and tool results
507
+ record what happened.
508
+ """
509
+
510
+ parts: list[str] = []
511
+ for message in messages:
512
+ role = message.get("role")
513
+ if role == "user":
514
+ text = _content_text(message.get("content"))
515
+ if text:
516
+ parts.append(f"[User]: {text}")
517
+ elif role == "assistant":
518
+ content = cast(Sequence[Mapping[str, Any]], message.get("content") or ())
519
+ texts = [cast(str, item["text"]) for item in content if item.get("type") == "text"]
520
+ calls = [_call_text(item) for item in content if item.get("type") == "toolCall"]
521
+ if texts:
522
+ parts.append("[Assistant]: " + "\n".join(texts))
523
+ if calls:
524
+ parts.append("[Assistant tool calls]: " + "; ".join(calls))
525
+ elif role == "toolResult":
526
+ text = _content_text(message.get("content"))
527
+ if text:
528
+ parts.append(f"[Tool result]: {_truncate(text, TOOL_RESULT_MAX_CHARS)}")
529
+ return "\n\n".join(parts)
530
+
531
+
532
+ def _content_text(content: Any) -> str:
533
+ if isinstance(content, str):
534
+ return content
535
+ return "\n".join(
536
+ cast(str, block["text"])
537
+ for block in cast(Sequence[Mapping[str, Any]], content or ())
538
+ if block.get("type") == "text" and block.get("text") is not None
539
+ )
540
+
541
+
542
+ def _call_text(item: Mapping[str, Any]) -> str:
543
+ """One tool call as ``name(key=json, ...)``, its arguments in their committed order."""
544
+
545
+ arguments = cast(Mapping[str, Any], item.get("arguments") or {})
546
+ fields = ", ".join(
547
+ f"{key}={json.dumps(value, separators=(',', ':'))}" for key, value in arguments.items()
548
+ )
549
+ return f"{item['name']}({fields})"
550
+
551
+
552
+ def _truncate(text: str, max_chars: int) -> str:
553
+ if len(text) <= max_chars:
554
+ return text
555
+ return f"{text[:max_chars]}\n\n[... {len(text) - max_chars} more characters truncated]"
556
+
557
+
558
+ async def place(runtime: Any, first_kept: EntryId, summary: str, context: Context) -> None:
559
+ """Place a summary supplied by a hook in its own commit."""
560
+
561
+ async def commit(tx: Transaction, current: Any) -> dict[str, Any]:
562
+ live = await tx.doc(LiveDoc, runtime.conversation_id)
563
+ return await _place_summary(tx, runtime, current, live, first_kept, summary)
564
+
565
+ await runtime.commit(commit, context)
566
+
567
+
568
+ async def _place_summary(
569
+ tx: Transaction,
570
+ runtime: Any,
571
+ task: TaskRecord,
572
+ live: Any,
573
+ first_kept: EntryId,
574
+ summary: str,
575
+ ) -> dict[str, Any]:
576
+ """Place the summary entry and complete (spec section 8.7).
577
+
578
+ A blocking compaction, owned by its generation, appends it: the generation holds the run and
579
+ waits. A conversation-owned one admits it as a write submission, placed at once when idle,
580
+ otherwise at the next boundary, or settled ``stale``.
581
+ """
582
+
583
+ remove_compaction_status(live, runtime.task_id)
584
+ clear_compaction_failure(live)
585
+ settings = runtime.settings
586
+ text = f"{SUMMARY_PREFIX}{summary}{SUMMARY_SUFFIX}"
587
+ entry = {
588
+ "kind": CompactionEntry.kind,
589
+ "head": first_kept,
590
+ "model": [{"role": "user", "content": [{"type": "text", "text": text}], "timestamp": runtime.now()}],
591
+ "data": {"reason": cast(CompactionInput, task["input"])["reason"]},
592
+ }
593
+ owner = task.get("owner")
594
+ result: CompactionResult
595
+ if owner is not None:
596
+ appended = await tx.append_entry(
597
+ CompactionEntry, runtime.conversation_id, cast(EntryDraft, entry)
598
+ )
599
+ result = {"entryId": appended["id"]}
600
+ else:
601
+ result = {
602
+ "submissionId": await admit_submission(
603
+ tx,
604
+ runtime.conversation_id,
605
+ {"type": "write", "requestId": f"compaction:{runtime.task_id}", "entry": entry},
606
+ runtime.now(),
607
+ QueueModes(settings.steering_mode, settings.follow_up_mode),
608
+ _start_run(runtime),
609
+ )
610
+ }
611
+ return {"status": "terminal", "outcome": {"status": "completed", "result": result}}
612
+
613
+
614
+ def _start_run(runtime: Any) -> Any:
615
+ """The Harness's ``start_run`` for a boundary a compaction-owned admission applies."""
616
+
617
+ async def start_run(
618
+ tx: Transaction, conversation_id: ConversationId, live: Any, inputs: list[Any]
619
+ ) -> None:
620
+ await start_generation(tx, conversation_id, live, inputs, runtime.registry)
621
+
622
+ return start_run
623
+
624
+
625
+ async def complete(runtime: Any, context: Context) -> None:
626
+ """Remove the status and complete without a summary."""
627
+
628
+ async def commit(tx: Transaction) -> dict[str, Any]:
629
+ remove_compaction_status(await tx.doc(LiveDoc, runtime.conversation_id), runtime.task_id)
630
+ return {"status": "terminal", "outcome": {"status": "completed", "result": {}}}
631
+
632
+ await runtime.commit(commit, context)
633
+
634
+
635
+ async def fail_no_model(runtime: Any, ref: ModelRef | None, context: Context) -> None:
636
+ message = (
637
+ "No model is configured"
638
+ if ref is None
639
+ else f"Model {ref['provider']}/{ref['modelId']} is not available"
640
+ )
641
+
642
+ async def fail(tx: Transaction) -> dict[str, Any]:
643
+ fail_compaction_status(await tx.doc(LiveDoc, runtime.conversation_id), runtime.task_id, message)
644
+ return {
645
+ "status": "terminal",
646
+ "outcome": {"status": "failed", "error": {"message": message, "detail": {"reason": "no_model"}}},
647
+ }
648
+
649
+ await runtime.commit(fail, context)
650
+
651
+
652
+ __all__ = [
653
+ "CompactionCheckpoint",
654
+ "CompactionInput",
655
+ "CompactionResult",
656
+ "CompactionTask",
657
+ "SUMMARIZATION_PROMPT",
658
+ "SUMMARIZATION_SYSTEM_PROMPT",
659
+ "SUMMARY_PREFIX",
660
+ "SUMMARY_SUFFIX",
661
+ "SummaryRequest",
662
+ "TOOL_RESULT_MAX_CHARS",
663
+ "complete",
664
+ "create_compaction",
665
+ "estimate_context",
666
+ "fail_no_model",
667
+ "place",
668
+ "select_cut",
669
+ "serialize_conversation",
670
+ "summary_failure",
671
+ "summary_prompt",
672
+ "summary_text",
673
+ ]