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