turnloop 0.1.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.
- turnloop/__init__.py +3 -0
- turnloop/__main__.py +4 -0
- turnloop/agent/__init__.py +0 -0
- turnloop/agent/factory.py +175 -0
- turnloop/agent/headless.py +150 -0
- turnloop/agent/loop.py +396 -0
- turnloop/agent/subagent.py +182 -0
- turnloop/agent/system_prompt.py +263 -0
- turnloop/cli.py +202 -0
- turnloop/commands/__init__.py +0 -0
- turnloop/commands/dispatch.py +428 -0
- turnloop/commands/loader.py +140 -0
- turnloop/config.py +425 -0
- turnloop/context/__init__.py +0 -0
- turnloop/context/budget.py +74 -0
- turnloop/context/compaction.py +441 -0
- turnloop/context/memory.py +120 -0
- turnloop/core/__init__.py +0 -0
- turnloop/core/events.py +145 -0
- turnloop/core/ids.py +29 -0
- turnloop/core/messages.py +217 -0
- turnloop/core/tokens.py +104 -0
- turnloop/diagnostics.py +233 -0
- turnloop/errors.py +64 -0
- turnloop/experiments/__init__.py +0 -0
- turnloop/experiments/configs/bakeoff.yaml +50 -0
- turnloop/experiments/configs/ceiling-check.yaml +31 -0
- turnloop/experiments/configs/context-glm.yaml +59 -0
- turnloop/experiments/configs/context.yaml +65 -0
- turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
- turnloop/experiments/configs/groq-free.yaml +28 -0
- turnloop/experiments/configs/hard-calibration.yaml +26 -0
- turnloop/experiments/configs/hard-glm.yaml +41 -0
- turnloop/experiments/configs/loop.yaml +34 -0
- turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
- turnloop/experiments/configs/reliability.yaml +44 -0
- turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
- turnloop/experiments/configs/smoke.yaml +22 -0
- turnloop/experiments/graders.py +209 -0
- turnloop/experiments/report.py +371 -0
- turnloop/experiments/runner.py +495 -0
- turnloop/experiments/suite/default.yaml +185 -0
- turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
- turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
- turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
- turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
- turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
- turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
- turnloop/experiments/suite/fixtures/empty/.keep +1 -0
- turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
- turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
- turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
- turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
- turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
- turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
- turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
- turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
- turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
- turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
- turnloop/experiments/suite/fixtures/rename/report.py +5 -0
- turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
- turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
- turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
- turnloop/experiments/suite/fixtures/util/util.py +9 -0
- turnloop/hooks/__init__.py +0 -0
- turnloop/hooks/runner.py +221 -0
- turnloop/mcp/__init__.py +0 -0
- turnloop/mcp/adapter.py +109 -0
- turnloop/mcp/client.py +299 -0
- turnloop/permissions/__init__.py +0 -0
- turnloop/permissions/engine.py +211 -0
- turnloop/permissions/rules.py +325 -0
- turnloop/providers/__init__.py +0 -0
- turnloop/providers/anthropic.py +318 -0
- turnloop/providers/base.py +329 -0
- turnloop/providers/gemini.py +217 -0
- turnloop/providers/mock.py +226 -0
- turnloop/providers/openai_compat.py +357 -0
- turnloop/providers/pricing.py +148 -0
- turnloop/providers/registry.py +75 -0
- turnloop/providers/sse.py +114 -0
- turnloop/sessions/__init__.py +0 -0
- turnloop/sessions/models.py +139 -0
- turnloop/sessions/store.py +271 -0
- turnloop/tools/__init__.py +0 -0
- turnloop/tools/ask.py +133 -0
- turnloop/tools/base.py +286 -0
- turnloop/tools/bash.py +423 -0
- turnloop/tools/builtin.py +63 -0
- turnloop/tools/edit.py +238 -0
- turnloop/tools/glob.py +235 -0
- turnloop/tools/grep.py +314 -0
- turnloop/tools/read.py +151 -0
- turnloop/tools/runner.py +284 -0
- turnloop/tools/shell.py +222 -0
- turnloop/tools/skill.py +89 -0
- turnloop/tools/task.py +161 -0
- turnloop/tools/textio.py +87 -0
- turnloop/tools/todo.py +140 -0
- turnloop/tools/webfetch.py +235 -0
- turnloop/tools/write.py +97 -0
- turnloop/tui/__init__.py +0 -0
- turnloop/tui/app.py +330 -0
- turnloop/tui/app.tcss +84 -0
- turnloop/tui/bridge.py +142 -0
- turnloop/tui/widgets/__init__.py +0 -0
- turnloop/tui/widgets/permission.py +131 -0
- turnloop/tui/widgets/status.py +110 -0
- turnloop/tui/widgets/transcript.py +146 -0
- turnloop-0.1.0.dist-info/METADATA +916 -0
- turnloop-0.1.0.dist-info/RECORD +132 -0
- turnloop-0.1.0.dist-info/WHEEL +4 -0
- turnloop-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Context compaction, in three escalating tiers.
|
|
2
|
+
|
|
3
|
+
tier 1 micro-compaction free, always on, no model call
|
|
4
|
+
tier 2 thinking drop free, at 60% pressure
|
|
5
|
+
tier 3 full summarization one model call, at 75% pressure
|
|
6
|
+
|
|
7
|
+
Tiers 1 and 2 cost nothing and typically reclaim 30-50% of a tool-heavy session,
|
|
8
|
+
which is why they run first: a summarization call that could have been avoided
|
|
9
|
+
costs money, latency, and fidelity.
|
|
10
|
+
|
|
11
|
+
The invariant this module must never violate: **a `tool_use` block and its
|
|
12
|
+
`tool_result` are never separated.** An orphaned `tool_use` is a hard 400 from
|
|
13
|
+
Anthropic, OpenAI and Gemini alike, and it happens exactly when the context is
|
|
14
|
+
already under pressure — the worst possible moment to lose a turn. It has its own
|
|
15
|
+
property test.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Literal
|
|
22
|
+
|
|
23
|
+
from turnloop.config import CompactionConfig
|
|
24
|
+
from turnloop.core.events import CompactionHappened
|
|
25
|
+
from turnloop.core.messages import (
|
|
26
|
+
ContentBlock,
|
|
27
|
+
Message,
|
|
28
|
+
TextBlock,
|
|
29
|
+
ThinkingBlock,
|
|
30
|
+
ToolResultBlock,
|
|
31
|
+
ToolUseBlock,
|
|
32
|
+
)
|
|
33
|
+
from turnloop.core.tokens import history_tokens, rough_tokens
|
|
34
|
+
from turnloop.sessions.models import Session
|
|
35
|
+
|
|
36
|
+
Tier = Literal["micro", "thinking", "full", "hard"]
|
|
37
|
+
|
|
38
|
+
SUMMARY_PROMPT = """\
|
|
39
|
+
Summarize the conversation so far so that another instance of you can continue \
|
|
40
|
+
the work with no other context. Be specific and concrete; this summary replaces \
|
|
41
|
+
the transcript.
|
|
42
|
+
|
|
43
|
+
Structure your answer under these headings:
|
|
44
|
+
|
|
45
|
+
1. Goal — what the user asked for, in their terms, including any constraint they
|
|
46
|
+
stated explicitly.
|
|
47
|
+
2. Decisions — what was decided and, importantly, what was considered and
|
|
48
|
+
rejected, with the reason. Without this the next instance re-proposes ideas
|
|
49
|
+
that were already turned down.
|
|
50
|
+
3. Files — every file read or modified, with the path and what changed in it.
|
|
51
|
+
4. State — what is working, what is not, and what has been verified versus
|
|
52
|
+
assumed.
|
|
53
|
+
5. Next step — the exact next action, specific enough to start immediately.
|
|
54
|
+
6. Open questions — anything unresolved or awaiting the user.
|
|
55
|
+
|
|
56
|
+
Do not include tool output verbatim. Do not pad. Facts only.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
SUMMARY_HEADER = "[Summary of the earlier conversation]"
|
|
60
|
+
MANIFEST_HEADER = "[Files touched so far]"
|
|
61
|
+
TRUNCATION_NOTE = "[truncated: {n} tokens elided. Re-run the tool if you need this again.]"
|
|
62
|
+
SUPERSEDED_NOTE = "[superseded by a later read of this file]"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class CompactionResult:
|
|
67
|
+
tier: Tier | None
|
|
68
|
+
tokens_before: int
|
|
69
|
+
tokens_after: int
|
|
70
|
+
summary: str = ""
|
|
71
|
+
replaced: tuple[int, int] = (0, 0)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def happened(self) -> bool:
|
|
75
|
+
return self.tier is not None
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def reclaimed(self) -> int:
|
|
79
|
+
return self.tokens_before - self.tokens_after
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Compactor:
|
|
84
|
+
config: CompactionConfig
|
|
85
|
+
max_context: int
|
|
86
|
+
provider: object | None = None # a Provider; only needed for tier 3
|
|
87
|
+
system: list[str] = field(default_factory=list)
|
|
88
|
+
ui: object | None = None
|
|
89
|
+
strategy: Literal["summarize", "truncate", "micro_only", "none"] = "summarize"
|
|
90
|
+
|
|
91
|
+
# --- thresholds --------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def tool_result_cap(self) -> int:
|
|
95
|
+
"""Tighter cap on small windows.
|
|
96
|
+
|
|
97
|
+
800 tokens of tool output is generous when the whole window is 57k of
|
|
98
|
+
usable space, and stingy when it is 190k. Scaling it is the difference
|
|
99
|
+
between compacting twice a session and compacting every other turn.
|
|
100
|
+
"""
|
|
101
|
+
if self.max_context <= self.config.small_context_threshold:
|
|
102
|
+
return self.config.small_max_tool_result_tokens
|
|
103
|
+
return self.config.max_tool_result_tokens
|
|
104
|
+
|
|
105
|
+
def budget_available(self, system_tokens: int, tools_tokens: int,
|
|
106
|
+
max_output: int = 8_192) -> int:
|
|
107
|
+
from turnloop.context.budget import output_reserve
|
|
108
|
+
|
|
109
|
+
reserve = output_reserve(self.config.reserve_output_tokens, max_output)
|
|
110
|
+
return max(1_000, self.max_context - reserve - system_tokens - tools_tokens)
|
|
111
|
+
|
|
112
|
+
# --- entry point -------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
async def maybe_compact(self, session: Session, available: int) -> CompactionResult:
|
|
115
|
+
"""Run whichever tiers the current pressure calls for.
|
|
116
|
+
|
|
117
|
+
Called before *every* request, not on a timer: pressure is a property of
|
|
118
|
+
what just happened, and one large tool result can cross two thresholds in
|
|
119
|
+
a single turn.
|
|
120
|
+
"""
|
|
121
|
+
before = history_tokens(session.messages)
|
|
122
|
+
if self.strategy == "none" or not self.config.enabled:
|
|
123
|
+
return CompactionResult(None, before, before)
|
|
124
|
+
|
|
125
|
+
applied: Tier | None = None
|
|
126
|
+
|
|
127
|
+
if self._micro_compact(session):
|
|
128
|
+
applied = "micro"
|
|
129
|
+
|
|
130
|
+
current = history_tokens(session.messages)
|
|
131
|
+
pressure = current / available
|
|
132
|
+
|
|
133
|
+
if pressure >= self.config.thinking_drop_pressure and self._drop_thinking(session):
|
|
134
|
+
applied = "thinking"
|
|
135
|
+
current = history_tokens(session.messages)
|
|
136
|
+
pressure = current / available
|
|
137
|
+
|
|
138
|
+
if self.strategy == "micro_only":
|
|
139
|
+
return await self._finish(session, applied, before, current)
|
|
140
|
+
|
|
141
|
+
if pressure >= self.config.full_pressure:
|
|
142
|
+
result = await self._full_compact(session, available)
|
|
143
|
+
if result.happened:
|
|
144
|
+
return await self._finish(
|
|
145
|
+
session, "full", before, result.tokens_after, result.summary, result.replaced
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Last-resort guard, deliberately outside the tier ladder. Summarization
|
|
149
|
+
# cannot help when the excess is a *single* message — there is no safe cut
|
|
150
|
+
# point in a four-message history whose third message is 500k characters —
|
|
151
|
+
# and sending a request known to exceed the window fails with a 400 and
|
|
152
|
+
# loses the turn. Truncating the body is strictly better than that.
|
|
153
|
+
if history_tokens(session.messages) > available:
|
|
154
|
+
_hard_truncate(session, available)
|
|
155
|
+
return await self._finish(
|
|
156
|
+
session, "hard", before, history_tokens(session.messages)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return await self._finish(session, applied, before, current)
|
|
160
|
+
|
|
161
|
+
async def _finish(self, session: Session, tier: Tier | None, before: int, after: int,
|
|
162
|
+
summary: str = "", replaced: tuple[int, int] = (0, 0)) -> CompactionResult:
|
|
163
|
+
result = CompactionResult(tier, before, after, summary, replaced)
|
|
164
|
+
if tier is not None:
|
|
165
|
+
session.compactions += 1
|
|
166
|
+
store = getattr(session, "store", None)
|
|
167
|
+
if store is not None:
|
|
168
|
+
store.write_compaction(tier, summary, replaced, before, after)
|
|
169
|
+
if self.ui is not None:
|
|
170
|
+
await self.ui.send(CompactionHappened(tier, before, after)) # type: ignore[attr-defined]
|
|
171
|
+
return result
|
|
172
|
+
|
|
173
|
+
# --- tier 1: micro-compaction -----------------------------------------
|
|
174
|
+
|
|
175
|
+
def _micro_compact(self, session: Session) -> bool:
|
|
176
|
+
"""Shrink old, large tool results in place. No model call.
|
|
177
|
+
|
|
178
|
+
Two transformations, both safe because the model can always re-run a tool:
|
|
179
|
+
oversized old results are head+tail truncated, and superseded reads of the
|
|
180
|
+
same file collapse to a one-line marker.
|
|
181
|
+
"""
|
|
182
|
+
messages = session.messages
|
|
183
|
+
keep_from = max(0, len(messages) - self.config.keep_recent_turns * 2)
|
|
184
|
+
changed = False
|
|
185
|
+
cap = self.tool_result_cap
|
|
186
|
+
|
|
187
|
+
# Later reads of a path make earlier ones redundant.
|
|
188
|
+
latest_read_of: dict[str, int] = {}
|
|
189
|
+
for i, msg in enumerate(messages):
|
|
190
|
+
for block in msg.content:
|
|
191
|
+
if isinstance(block, ToolUseBlock) and block.name in ("Read", "Glob", "Grep"):
|
|
192
|
+
path = str(block.args.get("file_path") or block.args.get("pattern") or "")
|
|
193
|
+
if path and block.name == "Read":
|
|
194
|
+
latest_read_of[path] = i
|
|
195
|
+
|
|
196
|
+
pending_supersede: set[str] = set()
|
|
197
|
+
for i, msg in enumerate(messages):
|
|
198
|
+
if i >= keep_from:
|
|
199
|
+
break
|
|
200
|
+
for block in msg.content:
|
|
201
|
+
if isinstance(block, ToolUseBlock) and block.name == "Read":
|
|
202
|
+
path = str(block.args.get("file_path") or "")
|
|
203
|
+
if path and latest_read_of.get(path, i) > i:
|
|
204
|
+
pending_supersede.add(block.id)
|
|
205
|
+
|
|
206
|
+
for i, msg in enumerate(messages):
|
|
207
|
+
if i >= keep_from:
|
|
208
|
+
break
|
|
209
|
+
for block in msg.content:
|
|
210
|
+
if not isinstance(block, ToolResultBlock) or block.truncated:
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
if block.tool_use_id in pending_supersede and not block.is_error:
|
|
214
|
+
block.content = SUPERSEDED_NOTE
|
|
215
|
+
block.truncated = True
|
|
216
|
+
changed = True
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
tokens = rough_tokens(block.content)
|
|
220
|
+
if tokens <= cap:
|
|
221
|
+
continue
|
|
222
|
+
block.content = _head_tail(block.content, cap)
|
|
223
|
+
block.truncated = True
|
|
224
|
+
changed = True
|
|
225
|
+
|
|
226
|
+
return changed
|
|
227
|
+
|
|
228
|
+
# --- tier 2: drop thinking --------------------------------------------
|
|
229
|
+
|
|
230
|
+
def _drop_thinking(self, session: Session) -> bool:
|
|
231
|
+
"""Remove reasoning from all but the most recent turns.
|
|
232
|
+
|
|
233
|
+
Reasoning is high-value while a turn is live and near-worthless two turns
|
|
234
|
+
later; for OpenAI-compatible providers it was never resent anyway, so on
|
|
235
|
+
those this is pure profit. On Anthropic it forfeits some cache reuse,
|
|
236
|
+
which is an acceptable trade at 60% pressure.
|
|
237
|
+
"""
|
|
238
|
+
messages = session.messages
|
|
239
|
+
keep_from = max(0, len(messages) - 4)
|
|
240
|
+
changed = False
|
|
241
|
+
for i, msg in enumerate(messages):
|
|
242
|
+
if i >= keep_from:
|
|
243
|
+
break
|
|
244
|
+
kept: list[ContentBlock] = [
|
|
245
|
+
b for b in msg.content if not isinstance(b, ThinkingBlock)
|
|
246
|
+
]
|
|
247
|
+
if len(kept) != len(msg.content):
|
|
248
|
+
# Never empty a message entirely; an empty assistant turn is
|
|
249
|
+
# rejected by some providers.
|
|
250
|
+
msg.content = kept or [TextBlock(text="(reasoning elided)")]
|
|
251
|
+
changed = True
|
|
252
|
+
return changed
|
|
253
|
+
|
|
254
|
+
# --- tier 3: summarize ------------------------------------------------
|
|
255
|
+
|
|
256
|
+
async def _full_compact(self, session: Session, available: int) -> CompactionResult:
|
|
257
|
+
messages = session.messages
|
|
258
|
+
before = history_tokens(messages)
|
|
259
|
+
cut = find_cut_point(messages, available, self.config.keep_recent_turns)
|
|
260
|
+
if cut <= 0:
|
|
261
|
+
return CompactionResult(None, before, before)
|
|
262
|
+
|
|
263
|
+
prefix, tail = messages[:cut], messages[cut:]
|
|
264
|
+
|
|
265
|
+
if self.strategy == "truncate" or self.provider is None:
|
|
266
|
+
summary = _mechanical_summary(prefix)
|
|
267
|
+
else:
|
|
268
|
+
summary = await self._summarize(prefix)
|
|
269
|
+
|
|
270
|
+
preserved: list[str] = [f"{SUMMARY_HEADER}\n{summary}"]
|
|
271
|
+
if todo := session.todo_summary():
|
|
272
|
+
# Todos are the model's own plan. Losing them mid-task is the most
|
|
273
|
+
# visible compaction failure there is, so they are carried verbatim.
|
|
274
|
+
preserved.append(f"[Current task list]\n{todo}")
|
|
275
|
+
if manifest := _file_manifest(prefix):
|
|
276
|
+
preserved.append(f"{MANIFEST_HEADER}\n{manifest}")
|
|
277
|
+
|
|
278
|
+
synthetic = Message.user_text("\n\n".join(preserved), compaction=True)
|
|
279
|
+
session.replace_history([synthetic, *tail])
|
|
280
|
+
|
|
281
|
+
after = history_tokens(session.messages)
|
|
282
|
+
if after > available:
|
|
283
|
+
_hard_truncate(session, available)
|
|
284
|
+
after = history_tokens(session.messages)
|
|
285
|
+
|
|
286
|
+
return CompactionResult("full", before, after, summary, (0, cut))
|
|
287
|
+
|
|
288
|
+
async def _summarize(self, prefix: list[Message]) -> str:
|
|
289
|
+
from turnloop.core.events import MessageDone
|
|
290
|
+
from turnloop.providers.base import CompletionRequest
|
|
291
|
+
|
|
292
|
+
request = CompletionRequest(
|
|
293
|
+
messages=[*prefix, Message.user_text(SUMMARY_PROMPT)],
|
|
294
|
+
system=["You are summarizing a coding session for your own successor."],
|
|
295
|
+
tools=[],
|
|
296
|
+
max_tokens=2_000,
|
|
297
|
+
temperature=0.0,
|
|
298
|
+
)
|
|
299
|
+
try:
|
|
300
|
+
async for event in self.provider.stream(request): # type: ignore[attr-defined]
|
|
301
|
+
if isinstance(event, MessageDone):
|
|
302
|
+
text = event.message.text.strip()
|
|
303
|
+
if text:
|
|
304
|
+
return text
|
|
305
|
+
except Exception as exc: # noqa: BLE001
|
|
306
|
+
# A failed summarization must not fail the turn — fall back to the
|
|
307
|
+
# mechanical summary, which is worse but always works.
|
|
308
|
+
return _mechanical_summary(prefix) + f"\n\n(summarization failed: {exc})"
|
|
309
|
+
return _mechanical_summary(prefix)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# --------------------------------------------------------------------------
|
|
313
|
+
# helpers
|
|
314
|
+
# --------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def find_cut_point(messages: list[Message], available: int, keep_recent_turns: int) -> int:
|
|
318
|
+
"""Index to cut history at, never splitting a tool_use/tool_result pair.
|
|
319
|
+
|
|
320
|
+
Walks backwards accumulating tokens until the retained tail is at least 25% of
|
|
321
|
+
the usable window, then snaps the boundary to a safe position: a `user`
|
|
322
|
+
message that is not carrying tool results. That is the only place in the
|
|
323
|
+
transcript where nothing is mid-exchange.
|
|
324
|
+
"""
|
|
325
|
+
if len(messages) <= 2:
|
|
326
|
+
return 0
|
|
327
|
+
|
|
328
|
+
target_tail = max(1, int(available * 0.25))
|
|
329
|
+
total = 0
|
|
330
|
+
cut = len(messages)
|
|
331
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
332
|
+
total += history_tokens([messages[i]])
|
|
333
|
+
cut = i
|
|
334
|
+
if total >= target_tail:
|
|
335
|
+
break
|
|
336
|
+
|
|
337
|
+
# Always keep the most recent exchanges regardless of the token maths.
|
|
338
|
+
cut = min(cut, max(0, len(messages) - keep_recent_turns * 2))
|
|
339
|
+
return snap_to_safe_boundary(messages, cut)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def snap_to_safe_boundary(messages: list[Message], cut: int) -> int:
|
|
343
|
+
"""Move `cut` earlier until it lands between complete exchanges."""
|
|
344
|
+
while cut > 0:
|
|
345
|
+
candidate = messages[cut]
|
|
346
|
+
starts_clean = candidate.role == "user" and not candidate.tool_results
|
|
347
|
+
previous_pending = bool(messages[cut - 1].tool_uses) and not starts_clean
|
|
348
|
+
if starts_clean and not previous_pending:
|
|
349
|
+
return cut
|
|
350
|
+
cut -= 1
|
|
351
|
+
return 0
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _head_tail(text: str, cap_tokens: int) -> str:
|
|
355
|
+
"""Keep the beginning and the end of an oversized tool result."""
|
|
356
|
+
from turnloop.core.tokens import CHARS_PER_TOKEN
|
|
357
|
+
|
|
358
|
+
cap_chars = int(cap_tokens * CHARS_PER_TOKEN)
|
|
359
|
+
if len(text) <= cap_chars:
|
|
360
|
+
return text
|
|
361
|
+
head = int(cap_chars * 0.6)
|
|
362
|
+
tail = cap_chars - head
|
|
363
|
+
elided = rough_tokens(text) - cap_tokens
|
|
364
|
+
return text[:head] + "\n" + TRUNCATION_NOTE.format(n=elided) + "\n" + text[-tail:]
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _mechanical_summary(messages: list[Message]) -> str:
|
|
368
|
+
"""A summary built without a model call.
|
|
369
|
+
|
|
370
|
+
Used when the strategy is `truncate`, when no provider is available, and as
|
|
371
|
+
the fallback if summarization fails. Deliberately factual: what was asked,
|
|
372
|
+
which tools ran, which files were touched.
|
|
373
|
+
"""
|
|
374
|
+
user_asks = [m.text.strip() for m in messages if m.role == "user" and m.text.strip()]
|
|
375
|
+
tools: dict[str, int] = {}
|
|
376
|
+
for msg in messages:
|
|
377
|
+
for block in msg.tool_uses:
|
|
378
|
+
tools[block.name] = tools.get(block.name, 0) + 1
|
|
379
|
+
|
|
380
|
+
parts = [f"{len(messages)} earlier messages were removed to free context."]
|
|
381
|
+
if user_asks:
|
|
382
|
+
parts.append("The user asked, in order:")
|
|
383
|
+
parts += [f" - {' '.join(a.split())[:200]}" for a in user_asks[:8]]
|
|
384
|
+
if tools:
|
|
385
|
+
listed = ", ".join(f"{name} x{count}" for name, count in sorted(tools.items()))
|
|
386
|
+
parts.append(f"Tools used: {listed}.")
|
|
387
|
+
if manifest := _file_manifest(messages):
|
|
388
|
+
parts.append("Files touched:\n" + manifest)
|
|
389
|
+
return "\n".join(parts)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _file_manifest(messages: list[Message]) -> str:
|
|
393
|
+
"""Paths touched, and whether they were modified.
|
|
394
|
+
|
|
395
|
+
Survives compaction verbatim because "which files am I working on" is the one
|
|
396
|
+
fact a summary cannot afford to paraphrase.
|
|
397
|
+
"""
|
|
398
|
+
seen: dict[str, bool] = {}
|
|
399
|
+
for msg in messages:
|
|
400
|
+
for block in msg.tool_uses:
|
|
401
|
+
path = block.args.get("file_path")
|
|
402
|
+
if not isinstance(path, str) or not path:
|
|
403
|
+
continue
|
|
404
|
+
modified = block.name in ("Write", "Edit")
|
|
405
|
+
seen[path] = seen.get(path, False) or modified
|
|
406
|
+
if not seen:
|
|
407
|
+
return ""
|
|
408
|
+
return "\n".join(
|
|
409
|
+
f" - {path}{' (modified)' if modified else ''}" for path, modified in sorted(seen.items())
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _hard_truncate(session: Session, available: int) -> None:
|
|
414
|
+
"""Last resort: shrink oversized message bodies directly.
|
|
415
|
+
|
|
416
|
+
Reached only when a single message is bigger than the whole window — a
|
|
417
|
+
pathological Read or a giant paste. Sending a request known to exceed the
|
|
418
|
+
context is never acceptable: it fails with a 400 and loses the turn.
|
|
419
|
+
"""
|
|
420
|
+
messages = session.messages
|
|
421
|
+
while history_tokens(messages) > available and messages:
|
|
422
|
+
biggest = max(range(len(messages)), key=lambda i: history_tokens([messages[i]]))
|
|
423
|
+
msg = messages[biggest]
|
|
424
|
+
changed = False
|
|
425
|
+
for block in msg.content:
|
|
426
|
+
if isinstance(block, TextBlock | ToolResultBlock):
|
|
427
|
+
text = block.content if isinstance(block, ToolResultBlock) else block.text
|
|
428
|
+
if len(text) <= 400:
|
|
429
|
+
continue
|
|
430
|
+
new_text = _head_tail(text, max(100, rough_tokens(text) // 4))
|
|
431
|
+
if isinstance(block, ToolResultBlock):
|
|
432
|
+
block.content = new_text
|
|
433
|
+
block.truncated = True
|
|
434
|
+
else:
|
|
435
|
+
block.text = new_text
|
|
436
|
+
changed = True
|
|
437
|
+
if not changed:
|
|
438
|
+
if len(messages) <= 2:
|
|
439
|
+
break
|
|
440
|
+
messages.pop(0)
|
|
441
|
+
session.replace_history(messages)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Project and user memory files.
|
|
2
|
+
|
|
3
|
+
Discovery order, nearest-last so the most specific instructions are read last and
|
|
4
|
+
therefore carry the most weight:
|
|
5
|
+
|
|
6
|
+
~/.turnloop/TURNLOOP.md
|
|
7
|
+
<repo root>/TURNLOOP.md
|
|
8
|
+
... every directory down to the working directory ...
|
|
9
|
+
<cwd>/TURNLOOP.md
|
|
10
|
+
|
|
11
|
+
`CLAUDE.md` and `AGENTS.md` are accepted under the same rules. Recognizing files
|
|
12
|
+
this project did not invent is not flattery — those files already exist in real
|
|
13
|
+
repositories and they contain exactly the instructions this harness needs.
|
|
14
|
+
|
|
15
|
+
Memory is capped. On a 65k window an enthusiastic 8k-token instruction file would
|
|
16
|
+
silently take an eighth of everything, so the cap warns rather than truncating in
|
|
17
|
+
silence.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from turnloop.core.tokens import rough_tokens
|
|
27
|
+
|
|
28
|
+
MEMORY_FILENAMES = ("TURNLOOP.md", "CLAUDE.md", "AGENTS.md")
|
|
29
|
+
IMPORT_RE = re.compile(r"^@([^\s]+)\s*$", re.MULTILINE)
|
|
30
|
+
MAX_IMPORT_DEPTH = 3
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class MemoryFile:
|
|
35
|
+
path: Path
|
|
36
|
+
content: str
|
|
37
|
+
scope: str # "user" | "project"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def tokens(self) -> int:
|
|
41
|
+
return rough_tokens(self.content)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def discover_memory(cwd: Path, project_root: Path) -> list[MemoryFile]:
|
|
45
|
+
files: list[MemoryFile] = []
|
|
46
|
+
|
|
47
|
+
for name in MEMORY_FILENAMES:
|
|
48
|
+
user_path = Path.home() / ".turnloop" / name
|
|
49
|
+
if user_path.is_file():
|
|
50
|
+
files.append(MemoryFile(user_path, _load(user_path), "user"))
|
|
51
|
+
break
|
|
52
|
+
|
|
53
|
+
# Root down to cwd, so a subdirectory's file is read after its parent's.
|
|
54
|
+
chain: list[Path] = []
|
|
55
|
+
current = cwd.resolve()
|
|
56
|
+
root = project_root.resolve()
|
|
57
|
+
while True:
|
|
58
|
+
chain.append(current)
|
|
59
|
+
if current == root or current.parent == current:
|
|
60
|
+
break
|
|
61
|
+
current = current.parent
|
|
62
|
+
for directory in reversed(chain):
|
|
63
|
+
for name in MEMORY_FILENAMES:
|
|
64
|
+
path = directory / name
|
|
65
|
+
if path.is_file():
|
|
66
|
+
files.append(MemoryFile(path, _load(path), "project"))
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
return files
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _load(path: Path, depth: int = 0, seen: set[Path] | None = None) -> str:
|
|
73
|
+
"""Read a memory file, resolving `@path` imports recursively."""
|
|
74
|
+
seen = seen or set()
|
|
75
|
+
resolved = path.resolve()
|
|
76
|
+
if resolved in seen or depth > MAX_IMPORT_DEPTH:
|
|
77
|
+
return ""
|
|
78
|
+
seen.add(resolved)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
82
|
+
except OSError:
|
|
83
|
+
return ""
|
|
84
|
+
|
|
85
|
+
def replace(match: re.Match[str]) -> str:
|
|
86
|
+
target = (path.parent / match.group(1)).expanduser()
|
|
87
|
+
if not target.is_file():
|
|
88
|
+
return f"(missing import: {match.group(1)})"
|
|
89
|
+
return _load(target, depth + 1, seen)
|
|
90
|
+
|
|
91
|
+
return IMPORT_RE.sub(replace, text)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def render_memory(files: list[MemoryFile], max_tokens: int = 4_000) -> str:
|
|
95
|
+
"""Concatenate with provenance headers, capped.
|
|
96
|
+
|
|
97
|
+
Provenance matters: when the model follows an instruction the user forgot they
|
|
98
|
+
wrote, the transcript should show which file it came from.
|
|
99
|
+
"""
|
|
100
|
+
if not files:
|
|
101
|
+
return ""
|
|
102
|
+
|
|
103
|
+
parts: list[str] = []
|
|
104
|
+
used = 0
|
|
105
|
+
dropped: list[str] = []
|
|
106
|
+
|
|
107
|
+
for memory in files:
|
|
108
|
+
if used + memory.tokens > max_tokens:
|
|
109
|
+
dropped.append(str(memory.path))
|
|
110
|
+
continue
|
|
111
|
+
used += memory.tokens
|
|
112
|
+
parts.append(f"--- from {memory.path} ({memory.scope}) ---\n{memory.content.strip()}")
|
|
113
|
+
|
|
114
|
+
rendered = "\n\n".join(parts)
|
|
115
|
+
if dropped:
|
|
116
|
+
rendered += (
|
|
117
|
+
f"\n\n[memory truncated: {', '.join(dropped)} omitted, "
|
|
118
|
+
f"{max_tokens} token budget reached]"
|
|
119
|
+
)
|
|
120
|
+
return rendered
|
|
File without changes
|