rlmness-cli 0.2.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 (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,557 @@
1
+ """Context accounting, threshold detection, and compaction planning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING
9
+
10
+ from rlmagent.harness.contracts.transcript import (
11
+ HumanEntry,
12
+ ModelEntry,
13
+ PruneSummaryEntry,
14
+ ToolOutcomeEntry,
15
+ TranscriptEntry,
16
+ surface_text,
17
+ )
18
+
19
+ if TYPE_CHECKING:
20
+ from rlmagent.harness.contracts.tooling import ToolSpec
21
+
22
+ # ── constants ───────────────────────────────────────────────────────────
23
+
24
+ CHARS_PER_TOKEN = 4
25
+ """Rough character-to-token ratio for estimation when no usage data exists."""
26
+
27
+ TOOL_SCHEMA_BASELINE = 200
28
+ """Estimated token overhead per tool definition (name + description + schema)."""
29
+
30
+ DEFAULT_WINDOW = 128_000
31
+ """Fallback context-window size when the model is not in ``MODEL_WINDOWS``."""
32
+
33
+ MODEL_WINDOWS: dict[str, int] = {
34
+ "claude-sonnet-4-20250514": 200_000,
35
+ "claude-opus-4-20250514": 200_000,
36
+ "claude-opus-4-6": 200_000,
37
+ "claude-sonnet-4-6": 200_000,
38
+ "claude-haiku-4-5-20251001": 200_000,
39
+ "gpt-4o": 128_000,
40
+ "gpt-4o-mini": 128_000,
41
+ }
42
+ """Known model context-window sizes in tokens."""
43
+
44
+ DEFAULT_SUMMARY_SYSTEM = (
45
+ "You are a concise summarizer. Produce a brief summary of the conversation "
46
+ "that preserves key decisions, code changes, file paths, and open questions. "
47
+ "Keep it under 500 words."
48
+ )
49
+ """Default system prompt sent to the model when generating compaction summaries."""
50
+
51
+ _INCREMENTAL_PREAMBLE = (
52
+ "The conversation was already compacted once. The previous summary is "
53
+ "included below as context. Merge it with the new material into a single "
54
+ "cohesive summary.\n\n"
55
+ "Previous summary:\n{prior_summary}\n\n"
56
+ "New material:\n"
57
+ )
58
+ """Template prepended to the user prompt when a prior compaction summary exists."""
59
+
60
+
61
+ # ── configuration ───────────────────────────────────────────────────────
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class ContextLimits:
66
+ """Configurable knobs governing context accounting and compaction policy."""
67
+
68
+ window: int = DEFAULT_WINDOW
69
+ """Maximum context-window size in tokens."""
70
+
71
+ compaction_threshold: float = 0.75
72
+ """Utilization ratio above which automatic compaction is triggered."""
73
+
74
+ keep_recent: int = 4
75
+ """Recent entries kept across a compaction when they fit ``keep_recent_tokens``."""
76
+
77
+ keep_recent_tokens: int = 20_000
78
+ """Token budget for the recent part kept word for word. A few huge tool
79
+ results must not survive a compaction whole and refill the window."""
80
+
81
+ reserve_tokens: int = 16_384
82
+ """Room left for the model's reply: compaction starts before the
83
+ transcript eats into it, whatever ``compaction_threshold`` says."""
84
+
85
+ min_entries: int = 6
86
+ """Transcript must be at least this long before compaction is allowed."""
87
+
88
+ max_summary_chars: int = 50_000
89
+ """Cap on the serialized text sent to the summarizer."""
90
+
91
+ summary_system: str = DEFAULT_SUMMARY_SYSTEM
92
+ """System prompt for the summarizer model call."""
93
+
94
+
95
+ # ── context estimation ──────────────────────────────────────────────────
96
+
97
+
98
+ @dataclass(frozen=True, slots=True)
99
+ class ContextEstimate:
100
+ """Snapshot of how much context is consumed and what the limits are."""
101
+
102
+ used: int
103
+ """Total estimated tokens currently in the context window."""
104
+
105
+ system: int
106
+ """Tokens consumed by the system prompt and tool definitions."""
107
+
108
+ messages: int
109
+ """Tokens consumed by transcript entries alone."""
110
+
111
+ limit: int
112
+ """The effective context-window size in tokens."""
113
+
114
+ threshold: float
115
+ """The compaction-trigger ratio (copied from ``ContextLimits``)."""
116
+
117
+ @property
118
+ def utilization(self) -> float:
119
+ """Fraction of the context window in use (0.0–1.0+)."""
120
+ if self.limit <= 0:
121
+ return 0.0
122
+ return self.used / self.limit
123
+
124
+ @property
125
+ def headroom(self) -> int:
126
+ """Tokens remaining before the hard window limit."""
127
+ return max(0, self.limit - self.used)
128
+
129
+ @property
130
+ def needs_compaction(self) -> bool:
131
+ """Whether utilization exceeds the compaction threshold."""
132
+ return self.utilization > self.threshold
133
+
134
+
135
+ def resolve_window(model: str, *, override: int | None = None) -> int:
136
+ """Return the context-window size for *model*."""
137
+ if override is not None:
138
+ return override
139
+ return MODEL_WINDOWS.get(model, DEFAULT_WINDOW)
140
+
141
+
142
+ def _chars_to_tokens(n: int) -> int:
143
+ """Convert a character count to an approximate token count."""
144
+ return max(1, n // CHARS_PER_TOKEN) if n > 0 else 0
145
+
146
+
147
+ def estimate_entry_tokens(entry: TranscriptEntry) -> int:
148
+ """Approximate the token footprint of a single transcript entry."""
149
+ if isinstance(entry, ModelEntry):
150
+ # Output tokens are what the model *produced*. Input tokens
151
+ # reflect the cumulative context at that turn, not the entry's
152
+ # own weight. Use output as the entry size.
153
+ reported = entry.usage.output
154
+ if reported > 0:
155
+ return reported
156
+
157
+ # No reported usage — estimate from content.
158
+ text_chars = len(entry.text) + len(entry.thinking_text)
159
+ call_chars = sum(
160
+ len(call.name) + len(json.dumps(dict(call.arguments), separators=(",", ":")))
161
+ for call in entry.tool_calls
162
+ )
163
+ return _chars_to_tokens(text_chars + call_chars)
164
+
165
+ if isinstance(entry, ToolOutcomeEntry):
166
+ text = surface_text(entry)
167
+ overhead = len(entry.tool_name) + len(entry.tool_call_id)
168
+ return _chars_to_tokens(len(text) + overhead)
169
+
170
+ if isinstance(entry, PruneSummaryEntry):
171
+ return _chars_to_tokens(len(entry.summary))
172
+
173
+ # HumanEntry, ShellResultEntry, ForkSummaryEntry, ExtensionEntry
174
+ return _chars_to_tokens(len(surface_text(entry)))
175
+
176
+
177
+ def estimate_tool_tokens(tools: Sequence[ToolSpec]) -> int:
178
+ """Approximate the tokens consumed by tool definitions in the system prompt."""
179
+ total = 0
180
+ for tool in tools:
181
+ schema_chars = len(json.dumps(dict(tool.parameters), separators=(",", ":")))
182
+ desc_chars = len(tool.description) + len(tool.name)
183
+ snippet_chars = len(tool.prompt_snippet or "")
184
+ guideline_chars = sum(len(g) for g in tool.prompt_guidelines)
185
+ total += TOOL_SCHEMA_BASELINE + _chars_to_tokens(
186
+ schema_chars + desc_chars + snippet_chars + guideline_chars
187
+ )
188
+ return total
189
+
190
+
191
+ def estimate_system_tokens(system: str, tools: Sequence[ToolSpec] = ()) -> int:
192
+ """Token estimate for the system prompt plus all tool definitions."""
193
+ return _chars_to_tokens(len(system)) + estimate_tool_tokens(tools)
194
+
195
+
196
+ def estimate_transcript_tokens(
197
+ transcript: Sequence[TranscriptEntry],
198
+ ) -> int:
199
+ """Sum the per-entry token estimates for the full transcript."""
200
+ last_input = 0
201
+ last_idx = -1
202
+ for i, entry in enumerate(transcript):
203
+ if isinstance(entry, ModelEntry) and _prompt_tokens(entry) > 0:
204
+ last_input = _prompt_tokens(entry)
205
+ last_idx = i
206
+
207
+ if last_idx < 0:
208
+ # No provider-reported usage at all — estimate everything.
209
+ return sum(estimate_entry_tokens(e) for e in transcript)
210
+
211
+ # Entries after the last-reported turn are new; estimate them.
212
+ tail = sum(estimate_entry_tokens(e) for e in transcript[last_idx + 1 :])
213
+ return last_input + tail
214
+
215
+
216
+ def _prompt_tokens(entry: ModelEntry) -> int:
217
+ """Full prompt size of the request that produced ``entry``."""
218
+ usage = entry.usage
219
+ return usage.input + usage.cache_read + usage.cache_write
220
+
221
+
222
+ def _has_reported_usage(transcript: Sequence[TranscriptEntry]) -> bool:
223
+ """Whether any turn carries provider-reported input token counts."""
224
+ return any(
225
+ isinstance(entry, ModelEntry) and _prompt_tokens(entry) > 0 for entry in transcript
226
+ )
227
+
228
+
229
+ def estimate_context(
230
+ transcript: Sequence[TranscriptEntry],
231
+ *,
232
+ system: str = "",
233
+ tools: Sequence[ToolSpec] = (),
234
+ limits: ContextLimits | None = None,
235
+ model: str = "",
236
+ window_override: int | None = None,
237
+ ) -> ContextEstimate:
238
+ """Build a full ``ContextEstimate`` for the current session state."""
239
+ cfg = limits or ContextLimits()
240
+ window = window_override or resolve_window(
241
+ model,
242
+ override=cfg.window if cfg.window != DEFAULT_WINDOW else None,
243
+ )
244
+ if window == DEFAULT_WINDOW and model:
245
+ window = resolve_window(model)
246
+
247
+ sys_tokens = estimate_system_tokens(system, tools)
248
+ msg_tokens = estimate_transcript_tokens(transcript)
249
+
250
+ if _has_reported_usage(transcript):
251
+ # A provider's reported ``input`` count already covers the system
252
+ # prompt and tool schemas that were sent with that turn, so adding the
253
+ # local system estimate on top would bill them twice. Keep the reported
254
+ # total authoritative and derive the message share from it.
255
+ used = msg_tokens
256
+ message_tokens = max(0, msg_tokens - sys_tokens)
257
+ else:
258
+ used = sys_tokens + msg_tokens
259
+ message_tokens = msg_tokens
260
+
261
+ return ContextEstimate(
262
+ used=used,
263
+ system=sys_tokens,
264
+ messages=message_tokens,
265
+ limit=window,
266
+ threshold=_effective_threshold(cfg, window),
267
+ )
268
+
269
+
270
+ def _effective_threshold(cfg: ContextLimits, window: int) -> float:
271
+ """The configured ratio, lowered when it would leave no room to reply."""
272
+ if window <= 0 or cfg.reserve_tokens <= 0 or cfg.reserve_tokens >= window:
273
+ return cfg.compaction_threshold
274
+ return min(cfg.compaction_threshold, (window - cfg.reserve_tokens) / window)
275
+
276
+
277
+ # ── threshold detection ─────────────────────────────────────────────────
278
+
279
+
280
+ def exceeds_threshold(estimate: ContextEstimate) -> bool:
281
+ """Return ``True`` when *estimate* indicates compaction should trigger."""
282
+ return estimate.needs_compaction
283
+
284
+
285
+ # ── compaction planning ─────────────────────────────────────────────────
286
+
287
+
288
+ @dataclass(frozen=True, slots=True)
289
+ class CompactionPlan:
290
+ """Describes exactly what a compaction operation will do."""
291
+
292
+ to_summarize: tuple[TranscriptEntry, ...]
293
+ """Older entries that will be replaced by a summary."""
294
+
295
+ to_keep: tuple[TranscriptEntry, ...]
296
+ """Recent entries that survive the compaction unmodified."""
297
+
298
+ summarize_ids: tuple[str, ...]
299
+ """Record IDs corresponding to *to_summarize* (for persistence)."""
300
+
301
+ keep_ids: tuple[str, ...]
302
+ """Record IDs corresponding to *to_keep*."""
303
+
304
+ tokens_before: int
305
+ """Estimated message tokens at the time the plan was created."""
306
+
307
+ has_prior_summary: bool
308
+ """Whether the first summarized entry is itself a compaction summary."""
309
+
310
+ prior_summary_text: str
311
+ """Text of the prior summary, or empty string."""
312
+
313
+
314
+ def _turn_boundary(transcript: Sequence[TranscriptEntry], split: int) -> int:
315
+ """Move *split* so the kept part starts a whole user turn."""
316
+ opening = max(
317
+ (i for i, e in enumerate(transcript[: split + 1]) if isinstance(e, HumanEntry)),
318
+ default=0,
319
+ )
320
+ if opening > 0:
321
+ return opening
322
+ for index in range(max(split, 0), len(transcript)):
323
+ if not isinstance(transcript[index], ToolOutcomeEntry):
324
+ return index
325
+ return len(transcript)
326
+
327
+
328
+ def _kept_from(transcript: Sequence[TranscriptEntry], budget: int, want: int) -> int:
329
+ """Earliest index whose tail fits *budget*, keeping at least one entry.
330
+
331
+ Walks back from the end. Stops early once *want* entries are kept and the
332
+ next one would cross the budget; never keeps more than the budget allows
333
+ beyond the final entry.
334
+ """
335
+ start = len(transcript) - 1
336
+ used = estimate_entry_tokens(transcript[start])
337
+ while start > 1:
338
+ cost = estimate_entry_tokens(transcript[start - 1])
339
+ if used + cost > budget:
340
+ break
341
+ used += cost
342
+ start -= 1
343
+ del want # the budget decides; the entry count only bounds the old rule
344
+ return start
345
+
346
+
347
+ def _split_point(transcript: Sequence[TranscriptEntry], cfg: ContextLimits) -> int:
348
+ """Where the summarised part ends and the kept part begins.
349
+
350
+ Keeps the recent tail that fits ``keep_recent_tokens``, capped at the old
351
+ count rule so short transcripts behave as before. Prefers a whole user
352
+ turn; when that turn alone is over budget, cuts inside it, after a tool
353
+ result rather than between a call and its result.
354
+ """
355
+ count = min(max(cfg.keep_recent, len(transcript) // 4), len(transcript) - 1)
356
+ by_count = len(transcript) - count
357
+ by_budget = _kept_from(transcript, cfg.keep_recent_tokens, count)
358
+ start = max(by_count, by_budget)
359
+
360
+ turn = _turn_boundary(transcript, start)
361
+ if 0 < turn < len(transcript):
362
+ kept = sum(estimate_entry_tokens(e) for e in transcript[turn:])
363
+ if kept <= cfg.keep_recent_tokens or turn == start:
364
+ return turn
365
+ # The turn is too big to keep whole: cut inside it.
366
+ for index in range(max(start, 1), len(transcript)):
367
+ if not isinstance(transcript[index], ToolOutcomeEntry):
368
+ return index
369
+ return len(transcript)
370
+
371
+
372
+ def plan_compaction(
373
+ transcript: Sequence[TranscriptEntry],
374
+ record_ids: Sequence[str],
375
+ *,
376
+ limits: ContextLimits | None = None,
377
+ ) -> CompactionPlan | None:
378
+ """Build a compaction plan, or ``None`` if the transcript is too short."""
379
+ cfg = limits or ContextLimits()
380
+
381
+ if len(transcript) < cfg.min_entries:
382
+ return None
383
+
384
+ split = _split_point(transcript, cfg)
385
+ if split < 1 or split >= len(transcript):
386
+ return None
387
+
388
+ to_summarize = tuple(transcript[:split])
389
+ to_keep = tuple(transcript[split:])
390
+
391
+ # Align record IDs with the same split.
392
+ ids = list(record_ids)
393
+ # Pad or truncate to match transcript length.
394
+ while len(ids) < len(transcript):
395
+ ids.append("")
396
+ summarize_ids = tuple(ids[:split])
397
+ keep_ids = tuple(ids[split:])
398
+
399
+ # Detect prior compaction summary for incremental updates.
400
+ has_prior = isinstance(to_summarize[0], PruneSummaryEntry)
401
+ prior_text = to_summarize[0].summary if has_prior else ""
402
+
403
+ return CompactionPlan(
404
+ to_summarize=to_summarize,
405
+ to_keep=to_keep,
406
+ summarize_ids=summarize_ids,
407
+ keep_ids=keep_ids,
408
+ tokens_before=estimate_transcript_tokens(transcript),
409
+ has_prior_summary=has_prior,
410
+ prior_summary_text=prior_text,
411
+ )
412
+
413
+
414
+ # ── message serialization ──────────────────────────────────────────────
415
+
416
+
417
+ def _role_label(entry: TranscriptEntry) -> str:
418
+ """Short human-readable role tag for serialization."""
419
+ if isinstance(entry, HumanEntry):
420
+ return "user"
421
+ if isinstance(entry, ModelEntry):
422
+ return "assistant"
423
+ if isinstance(entry, ToolOutcomeEntry):
424
+ return "tool"
425
+ if isinstance(entry, PruneSummaryEntry):
426
+ return "summary"
427
+ return entry.role
428
+
429
+
430
+ def serialize_for_summary(
431
+ entries: Sequence[TranscriptEntry],
432
+ *,
433
+ max_chars: int = 50_000,
434
+ ) -> str:
435
+ """Render transcript entries as plain text for the summarizer."""
436
+ parts: list[str] = []
437
+ total = 0
438
+ for entry in entries:
439
+ role = _role_label(entry)
440
+ body = surface_text(entry)
441
+
442
+ # Include tool-call details for assistant entries.
443
+ if isinstance(entry, ModelEntry) and entry.tool_calls:
444
+ call_lines = []
445
+ for call in entry.tool_calls:
446
+ args_str = json.dumps(dict(call.arguments), separators=(",", ":"))
447
+ if len(args_str) > 500:
448
+ args_str = args_str[:500] + "..."
449
+ call_lines.append(f" -> {call.name}({args_str})")
450
+ body = body + "\n" + "\n".join(call_lines) if body else "\n".join(call_lines)
451
+
452
+ line = f"[{role}] {body}"
453
+ if total + len(line) > max_chars:
454
+ remaining = max_chars - total
455
+ if remaining > 20:
456
+ parts.append(line[:remaining])
457
+ parts.append("[...truncated...]")
458
+ break
459
+ parts.append(line)
460
+ total += len(line) + 1 # +1 for newline
461
+
462
+ return "\n".join(parts)
463
+
464
+
465
+ # ── summary prompt construction ─────────────────────────────────────────
466
+
467
+
468
+ def build_summary_prompts(
469
+ plan: CompactionPlan,
470
+ *,
471
+ system: str = DEFAULT_SUMMARY_SYSTEM,
472
+ max_chars: int = 50_000,
473
+ ) -> tuple[str, str]:
474
+ """Build the (system, user) prompts for the summarizer call."""
475
+ serialized = serialize_for_summary(plan.to_summarize, max_chars=max_chars)
476
+
477
+ if plan.has_prior_summary and plan.prior_summary_text:
478
+ user_prompt = _INCREMENTAL_PREAMBLE.format(
479
+ prior_summary=plan.prior_summary_text
480
+ ) + serialized
481
+ else:
482
+ user_prompt = f"Summarize this conversation:\n\n{serialized}"
483
+
484
+ return (system, user_prompt)
485
+
486
+
487
+ # ── applying compaction ─────────────────────────────────────────────────
488
+
489
+
490
+ @dataclass(frozen=True, slots=True)
491
+ class CompactionResult:
492
+ """The output of applying a compaction plan with a generated summary."""
493
+
494
+ transcript: tuple[TranscriptEntry, ...]
495
+ """The compacted transcript: summary entry followed by kept entries."""
496
+
497
+ record_ids: tuple[str, ...]
498
+ """Record IDs aligned with the new transcript."""
499
+
500
+ summary_entry: PruneSummaryEntry
501
+ """The summary entry that replaced the older messages."""
502
+
503
+ tokens_before: int
504
+ """Message tokens at the time of planning."""
505
+
506
+ tokens_after: int
507
+ """Estimated message tokens after compaction."""
508
+
509
+
510
+ def apply_compaction(
511
+ plan: CompactionPlan,
512
+ summary: str,
513
+ *,
514
+ summary_record_id: str = "",
515
+ ) -> CompactionResult:
516
+ """Combine a compaction plan with a generated summary into a result."""
517
+ summary_entry = PruneSummaryEntry(
518
+ summary=summary,
519
+ tokens_before=plan.tokens_before,
520
+ )
521
+
522
+ new_transcript = (summary_entry, *plan.to_keep)
523
+ new_ids = (summary_record_id, *plan.keep_ids)
524
+
525
+ tokens_after = estimate_transcript_tokens(list(new_transcript))
526
+
527
+ return CompactionResult(
528
+ transcript=new_transcript,
529
+ record_ids=new_ids,
530
+ summary_entry=summary_entry,
531
+ tokens_before=plan.tokens_before,
532
+ tokens_after=tokens_after,
533
+ )
534
+
535
+
536
+ __all__ = [
537
+ "CHARS_PER_TOKEN",
538
+ "CompactionPlan",
539
+ "CompactionResult",
540
+ "ContextEstimate",
541
+ "ContextLimits",
542
+ "DEFAULT_SUMMARY_SYSTEM",
543
+ "DEFAULT_WINDOW",
544
+ "MODEL_WINDOWS",
545
+ "TOOL_SCHEMA_BASELINE",
546
+ "apply_compaction",
547
+ "build_summary_prompts",
548
+ "estimate_context",
549
+ "estimate_entry_tokens",
550
+ "estimate_system_tokens",
551
+ "estimate_tool_tokens",
552
+ "estimate_transcript_tokens",
553
+ "exceeds_threshold",
554
+ "plan_compaction",
555
+ "resolve_window",
556
+ "serialize_for_summary",
557
+ ]