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.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|