localcode 0.4.0__py3-none-macosx_13_0_arm64.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.
- localcode/__init__.py +11 -0
- localcode/__main__.py +5 -0
- localcode/_subproc_env.py +54 -0
- localcode/agent/__init__.py +177 -0
- localcode/agent/app_tasks.py +157 -0
- localcode/agent/constants.py +275 -0
- localcode/agent/context.py +1152 -0
- localcode/agent/goal.py +244 -0
- localcode/agent/helpers.py +1036 -0
- localcode/agent/hollow_module.py +230 -0
- localcode/agent/hooks.py +414 -0
- localcode/agent/loop.py +2855 -0
- localcode/agent/plateau.py +297 -0
- localcode/agent/project_check_gate.py +91 -0
- localcode/agent/prompt_context.py +264 -0
- localcode/agent/prompts.py +368 -0
- localcode/agent/reasoning_loop.py +79 -0
- localcode/agent/recovery.py +606 -0
- localcode/agent/recovery_controller.py +25 -0
- localcode/agent/round_policy.py +23 -0
- localcode/agent/sections.py +241 -0
- localcode/agent/state_machine.py +100 -0
- localcode/agent/streaming.py +324 -0
- localcode/agent/tool_execution.py +368 -0
- localcode/agent/tool_orchestration.py +54 -0
- localcode/agent/turn_finalization.py +135 -0
- localcode/app.py +1595 -0
- localcode/approvals.py +17 -0
- localcode/auto_compact.py +176 -0
- localcode/autonomy.py +103 -0
- localcode/bin/__init__.py +0 -0
- localcode/bin/llama-server +0 -0
- localcode/bin/localcode-ui +4 -0
- localcode/bootstrap.py +1191 -0
- localcode/cache.py +194 -0
- localcode/checkpoint.py +375 -0
- localcode/compact.py +73 -0
- localcode/compaction.py +341 -0
- localcode/composer.py +72 -0
- localcode/config.py +521 -0
- localcode/context.py +161 -0
- localcode/display.py +58 -0
- localcode/embeddings.py +480 -0
- localcode/entrypoint.py +536 -0
- localcode/errors.py +350 -0
- localcode/events.py +409 -0
- localcode/evidence.py +102 -0
- localcode/execution_policy.py +48 -0
- localcode/features.py +308 -0
- localcode/formatting.py +91 -0
- localcode/headless_json.py +265 -0
- localcode/health.py +288 -0
- localcode/hf_quants.py +203 -0
- localcode/history.py +594 -0
- localcode/indexer.py +144 -0
- localcode/injection_defense.py +204 -0
- localcode/launcher.py +373 -0
- localcode/logging_utils.py +28 -0
- localcode/lsp.py +153 -0
- localcode/mcp/__init__.py +88 -0
- localcode/mcp/_bridge.py +61 -0
- localcode/mcp/_config.py +33 -0
- localcode/mcp/_transports.py +128 -0
- localcode/mcp/client.py +395 -0
- localcode/memory_guard.py +291 -0
- localcode/model_config.py +288 -0
- localcode/model_delete.py +444 -0
- localcode/model_families.py +279 -0
- localcode/models.py +236 -0
- localcode/models_catalog.py +901 -0
- localcode/notebook.py +123 -0
- localcode/output.py +403 -0
- localcode/patching.py +65 -0
- localcode/paths.py +303 -0
- localcode/performance.py +623 -0
- localcode/permissions.py +79 -0
- localcode/permissions_v2.py +310 -0
- localcode/plans.py +198 -0
- localcode/process_registry.py +271 -0
- localcode/protocol/__init__.py +56 -0
- localcode/protocol/events.py +116 -0
- localcode/protocol/jsonl.py +153 -0
- localcode/protocol/outcomes.py +231 -0
- localcode/reasoning_capabilities.py +59 -0
- localcode/recommendations.py +87 -0
- localcode/recovery.py +134 -0
- localcode/redaction.py +164 -0
- localcode/runtime.py +2331 -0
- localcode/server_manager.py +1069 -0
- localcode/session.py +296 -0
- localcode/shell.py +83 -0
- localcode/skills/debug.md +21 -0
- localcode/skills/edit-verified.md +30 -0
- localcode/skills/explain.md +19 -0
- localcode/skills/finish-verified.md +11 -0
- localcode/skills/git-commit-safely.md +30 -0
- localcode/skills/locate.md +21 -0
- localcode/skills/plan-task.md +51 -0
- localcode/skills/review.md +22 -0
- localcode/skills/run-tests.md +24 -0
- localcode/skills.py +728 -0
- localcode/snapshots.py +195 -0
- localcode/sounds.py +50 -0
- localcode/telemetry.py +273 -0
- localcode/theme.py +137 -0
- localcode/thermal.py +160 -0
- localcode/thinking.py +114 -0
- localcode/tool_router.py +246 -0
- localcode/toolkit.py +1241 -0
- localcode/tools/__init__.py +400 -0
- localcode/tools/agent.py +259 -0
- localcode/tools/append_file.py +55 -0
- localcode/tools/background_process.py +115 -0
- localcode/tools/base.py +168 -0
- localcode/tools/bash.py +1160 -0
- localcode/tools/code_navigation.py +97 -0
- localcode/tools/edit_diff.py +80 -0
- localcode/tools/edit_file.py +477 -0
- localcode/tools/facts.py +149 -0
- localcode/tools/glob_tool.py +60 -0
- localcode/tools/grep.py +65 -0
- localcode/tools/inspect_symbol.py +266 -0
- localcode/tools/launch_app.py +93 -0
- localcode/tools/list_files.py +42 -0
- localcode/tools/multi_edit.py +167 -0
- localcode/tools/plan_mode.py +86 -0
- localcode/tools/project_check.py +685 -0
- localcode/tools/read_file.py +261 -0
- localcode/tools/read_state.py +223 -0
- localcode/tools/skill_tool.py +41 -0
- localcode/tools/syntax_check.py +216 -0
- localcode/tools/todo_write.py +200 -0
- localcode/tools/tool_call_repair.py +157 -0
- localcode/tools/web_fetch.py +182 -0
- localcode/tools/web_search.py +45 -0
- localcode/tools/write_file.py +281 -0
- localcode/tui/__init__.py +1 -0
- localcode/tui/app.py +485 -0
- localcode/tui/bridge.py +78 -0
- localcode/tui/clipboard_image.py +153 -0
- localcode/tui/paste_collapse.py +95 -0
- localcode/tui/screens/__init__.py +0 -0
- localcode/tui/screens/chat.py +5052 -0
- localcode/tui/screens/confirm.py +123 -0
- localcode/tui/screens/mode_picker.py +79 -0
- localcode/tui/screens/model_picker.py +962 -0
- localcode/tui/screens/setup.py +891 -0
- localcode/tui/styles/__init__.py +0 -0
- localcode/tui/styles/app.tcss +197 -0
- localcode/tui/widgets/__init__.py +0 -0
- localcode/tui/widgets/approval.py +2 -0
- localcode/tui/widgets/chat_log.py +2063 -0
- localcode/tui/widgets/messages/__init__.py +21 -0
- localcode/tui/widgets/messages/diff.py +287 -0
- localcode/tui/widgets/voice_visualizer.py +122 -0
- localcode/turn_diff.py +180 -0
- localcode/ui/FORK_COMMIT +1 -0
- localcode/ui/__init__.py +58 -0
- localcode/ui/launch.py +204 -0
- localcode/ui/picker_cli.py +165 -0
- localcode/ui/plugin/localcode.ts +626 -0
- localcode/ui/ports.py +42 -0
- localcode/ui/server_cmd.py +89 -0
- localcode/ui/supervisor.py +736 -0
- localcode/undo.py +121 -0
- localcode/verification.py +87 -0
- localcode/voice.py +948 -0
- localcode-0.4.0.dist-info/METADATA +160 -0
- localcode-0.4.0.dist-info/RECORD +173 -0
- localcode-0.4.0.dist-info/WHEEL +5 -0
- localcode-0.4.0.dist-info/entry_points.txt +3 -0
- localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
- localcode-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1152 @@
|
|
|
1
|
+
"""Context-management pipeline — redaction, compaction, aging.
|
|
2
|
+
|
|
3
|
+
Extracted from agent/__init__.py during T0.1-c. No behaviour change —
|
|
4
|
+
every function here is verbatim from the pre-split module. The loop
|
|
5
|
+
imports them from `.context` instead of having them in the same file.
|
|
6
|
+
|
|
7
|
+
Why these nine functions together:
|
|
8
|
+
They all shape the `messages` list that goes to the model. Some
|
|
9
|
+
truncate tool results inline (`_truncate_result`). Some age older
|
|
10
|
+
tool-call payloads so the context window doesn't explode
|
|
11
|
+
(`_redact_old_write_args`, `_redact_duplicate_reads`,
|
|
12
|
+
`_compact_old_tool_results`). Some bound / estimate the total
|
|
13
|
+
(`_msg_bytes`, `_estimate_tokens`). The top-level `_prepare_model_messages`
|
|
14
|
+
composes the aging pipeline in order. `_compact_messages` is the
|
|
15
|
+
LLM-summary fallback when the deterministic pipeline still leaves
|
|
16
|
+
the context too big. `_summarize_args` formats tool-call args for
|
|
17
|
+
both compaction-summary text and live tool logging.
|
|
18
|
+
|
|
19
|
+
Nothing here is loop logic or model-specific. Pure functions over the
|
|
20
|
+
message-list shape we pass to the runtime.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
from .constants import (
|
|
28
|
+
COMPACT_KEEP_RECENT_TOOL_RESULTS,
|
|
29
|
+
COMPACT_MIN_CONTENT_CHARS,
|
|
30
|
+
REDACT_KEEP_RECENT_WRITES,
|
|
31
|
+
REDACT_MIN_CONTENT_CHARS,
|
|
32
|
+
READ_UNCHANGED_STUB_PREFIX,
|
|
33
|
+
RESULT_LIMITS,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from ..output import OutputManager
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Every helper here is intentionally private (underscore-prefixed).
|
|
41
|
+
# The aging pipeline internals may be reshaped freely as long as
|
|
42
|
+
# `_prepare_model_messages` and `_compact_messages` keep the
|
|
43
|
+
# behaviour the loop expects. `__all__` is empty deliberately —
|
|
44
|
+
# callers import by name rather than through a public surface.
|
|
45
|
+
__all__: list[str] = []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _spill_tool_output(result: str, tool_name: str) -> str | None:
|
|
49
|
+
"""Write an oversized tool result to a file and return its path.
|
|
50
|
+
|
|
51
|
+
Truncation alone makes a small model re-run the command "to see the rest".
|
|
52
|
+
Instead we keep the full output on disk and point the model at it (grep/
|
|
53
|
+
read the exact part). Best-effort: None on error → caller falls back to
|
|
54
|
+
plain truncation. Content-hashed filenames; old spills reaped after 7 days.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
import hashlib
|
|
58
|
+
import time as _time
|
|
59
|
+
from ..paths import chmod_quiet, global_state_dir, write_private
|
|
60
|
+
spill_dir = global_state_dir() / "tool_output"
|
|
61
|
+
existed = spill_dir.is_dir()
|
|
62
|
+
spill_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
if not existed:
|
|
64
|
+
chmod_quiet(spill_dir, 0o700)
|
|
65
|
+
# Reap spills older than 7 days so this never grows unbounded.
|
|
66
|
+
try:
|
|
67
|
+
cutoff = _time.time() - 7 * 86400
|
|
68
|
+
for old in spill_dir.glob("*.txt"):
|
|
69
|
+
if old.stat().st_mtime < cutoff:
|
|
70
|
+
old.unlink(missing_ok=True)
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
digest = hashlib.sha1(result.encode("utf-8", "replace")).hexdigest()[:12]
|
|
74
|
+
path = spill_dir / f"{tool_name}_{digest}.txt"
|
|
75
|
+
if not path.exists():
|
|
76
|
+
# Spilled tool output can be file contents, command output or
|
|
77
|
+
# anything else the agent read — owner-read only.
|
|
78
|
+
write_private(path, result)
|
|
79
|
+
return str(path)
|
|
80
|
+
except Exception:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# How much of the context window (in chars) a single tool result may occupy,
|
|
85
|
+
# per tool. The static RESULT_LIMITS values act as the FLOOR, so small machines
|
|
86
|
+
# (64K window ≈ 224K chars → these fractions fall below the floor) are
|
|
87
|
+
# byte-identical to before, while a big machine (256K ≈ 900K chars) lets the
|
|
88
|
+
# model keep far more of the output it has room for instead of a fixed 20–50K
|
|
89
|
+
# slice. grep matches are dense/repetitive so get the smallest share; read_file
|
|
90
|
+
# is the primary code-ingestion path so gets the most.
|
|
91
|
+
_RESULT_LIMIT_WINDOW_FRACTION: dict[str, float] = {
|
|
92
|
+
"grep": 0.03,
|
|
93
|
+
"bash": 0.05,
|
|
94
|
+
"read_file": 0.08,
|
|
95
|
+
"default": 0.08,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _dynamic_result_limit(tool_name: str, ctx_tokens: int) -> int:
|
|
100
|
+
"""Per-tool truncation budget (chars), scaled to the real context window.
|
|
101
|
+
|
|
102
|
+
Returns the static RESULT_LIMITS value as a floor; on a large window the
|
|
103
|
+
limit grows to a fraction of the window so the model isn't starved of output
|
|
104
|
+
it has room for. web_search and any tool without a fraction stay at the
|
|
105
|
+
fixed floor (web results don't benefit from window-scaling).
|
|
106
|
+
"""
|
|
107
|
+
floor = RESULT_LIMITS.get(tool_name, RESULT_LIMITS["default"])
|
|
108
|
+
frac = _RESULT_LIMIT_WINDOW_FRACTION.get(tool_name)
|
|
109
|
+
if not ctx_tokens or frac is None:
|
|
110
|
+
return floor
|
|
111
|
+
# CHARS_PER_TOKEN=4 is the internal estimate; use 3.5 to match loop.py's
|
|
112
|
+
# tokens→chars conversion and stay conservative.
|
|
113
|
+
return max(floor, int(ctx_tokens * 3.5 * frac))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _truncate_result(result: str, tool_name: str, ctx_tokens: int = 0) -> str:
|
|
117
|
+
"""Truncate a tool result to its per-tool size limit with a
|
|
118
|
+
strategy tuned to what each tool's output actually looks like.
|
|
119
|
+
|
|
120
|
+
`ctx_tokens` (the model's real context window) scales the limit up on big
|
|
121
|
+
machines; 0 falls back to the static floor (small machines unchanged).
|
|
122
|
+
|
|
123
|
+
Strategies:
|
|
124
|
+
• `read_file` — keep HEAD only. File content is ordered; the
|
|
125
|
+
model can paginate with `offset=` to see more. Middle-dropping
|
|
126
|
+
gives the model the illusion of complete context when it only
|
|
127
|
+
saw half. Replace dropped tail with an explicit offset hint.
|
|
128
|
+
• `grep` — keep HEAD only. Individual matches are small; losing
|
|
129
|
+
trailing matches to a count is fine (the hint tells the
|
|
130
|
+
model to narrow the pattern if it needs more).
|
|
131
|
+
• `bash` — prioritise the TAIL (most recent output, which
|
|
132
|
+
typically contains the error / result) plus a small HEAD
|
|
133
|
+
(which often contains the command echo + initial status).
|
|
134
|
+
• default — original middle-drop, a reasonable fallback for
|
|
135
|
+
tools whose output structure we haven't characterised.
|
|
136
|
+
|
|
137
|
+
Returns the result unchanged when it's within the limit.
|
|
138
|
+
"""
|
|
139
|
+
limit = _dynamic_result_limit(tool_name, ctx_tokens)
|
|
140
|
+
if len(result) <= limit:
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
dropped = len(result) - limit
|
|
144
|
+
# Spill the FULL output to disk and point the model at it, so it fetches
|
|
145
|
+
# the exact part it needs instead of re-running the command to "see the
|
|
146
|
+
# rest". Appended to every strategy's hint below.
|
|
147
|
+
_spill = _spill_tool_output(result, tool_name)
|
|
148
|
+
spill_hint = (
|
|
149
|
+
f" Full output saved to {_spill} — read_file (with offset) or grep it "
|
|
150
|
+
f"to see the rest; do NOT re-run the command."
|
|
151
|
+
if _spill else ""
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if tool_name == "read_file":
|
|
155
|
+
# Keep the HEAD; if we can find line numbers in the content
|
|
156
|
+
# (read_file numbers lines "N\t..."), compute the offset the
|
|
157
|
+
# model should call with next.
|
|
158
|
+
head = result[:limit]
|
|
159
|
+
# Estimate next-offset by counting newlines in head + 1.
|
|
160
|
+
# Line-numbered output makes this tighter than any heuristic.
|
|
161
|
+
last_numbered = None
|
|
162
|
+
for line in head.rsplit("\n", 200)[-200:]:
|
|
163
|
+
# Lines look like "1234\tsome content". Pull leading int.
|
|
164
|
+
if "\t" in line and line.split("\t", 1)[0].isdigit():
|
|
165
|
+
last_numbered = int(line.split("\t", 1)[0])
|
|
166
|
+
hint = (
|
|
167
|
+
f"\n\n[truncated — {dropped} more chars not shown. "
|
|
168
|
+
f"{'Call read_file with offset=' + str(last_numbered) + ' to continue reading from there.' if last_numbered else 'Call read_file with a larger offset to continue.'}{spill_hint}]"
|
|
169
|
+
)
|
|
170
|
+
return head + hint
|
|
171
|
+
|
|
172
|
+
if tool_name == "grep":
|
|
173
|
+
# Keep HEAD only. Grep output is one-match-per-line; losing
|
|
174
|
+
# trailing matches to a counter is OK — if the model needed
|
|
175
|
+
# more hits, it should narrow the pattern.
|
|
176
|
+
head = result[:limit]
|
|
177
|
+
# Count how many lines we've dropped so the model knows
|
|
178
|
+
# whether to broaden or narrow.
|
|
179
|
+
dropped_lines = result[limit:].count("\n") + 1
|
|
180
|
+
hint = (
|
|
181
|
+
f"\n\n[truncated — {dropped_lines} more match-lines not shown. "
|
|
182
|
+
f"Narrow with a more specific pattern or `include=*.py` to see them.{spill_hint}]"
|
|
183
|
+
)
|
|
184
|
+
return head + hint
|
|
185
|
+
|
|
186
|
+
if tool_name == "bash":
|
|
187
|
+
# Bash output is the noisiest path in long app tasks. Prefer a
|
|
188
|
+
# structured summary over raw head/tail when the output looks
|
|
189
|
+
# like a directory tree or a huge install/build log; otherwise
|
|
190
|
+
# keep the most recent tail with a short head for context.
|
|
191
|
+
lines = result.splitlines()
|
|
192
|
+
non_empty = [ln for ln in lines if ln.strip()]
|
|
193
|
+
if len(lines) >= 40:
|
|
194
|
+
first_sig = "\n".join(non_empty[:8])
|
|
195
|
+
last_sig = "\n".join(non_empty[-12:]) if len(non_empty) > 12 else ""
|
|
196
|
+
marker = (
|
|
197
|
+
f"\n\n[... {dropped} chars of bash output compressed.{spill_hint} ...]\n\n"
|
|
198
|
+
)
|
|
199
|
+
if any(token in result for token in ("backend", "frontend", "src/", "node_modules", "package.json", "pyproject.toml")):
|
|
200
|
+
tree_hint = (
|
|
201
|
+
f"[bash output summarized: {len(lines)} lines, "
|
|
202
|
+
f"{len(non_empty)} non-empty lines]\n"
|
|
203
|
+
)
|
|
204
|
+
return tree_hint + first_sig + marker + last_sig
|
|
205
|
+
if any(token in result.lower() for token in ("vite", "webpack", "npm run", "uvicorn", "fastapi", "build", "install")):
|
|
206
|
+
log_hint = (
|
|
207
|
+
f"[bash output summarized: {len(lines)} lines, build/install log]\n"
|
|
208
|
+
)
|
|
209
|
+
return log_hint + first_sig + marker + last_sig
|
|
210
|
+
# Prioritise TAIL (recent output = errors, final result)
|
|
211
|
+
# with a small HEAD for command echo / initial status.
|
|
212
|
+
head_size = min(limit // 4, 1000)
|
|
213
|
+
tail_size = limit - head_size - 64 # 64 chars for the marker line
|
|
214
|
+
head = result[:head_size]
|
|
215
|
+
tail = result[-tail_size:]
|
|
216
|
+
marker = f"\n\n[... {dropped} chars of middle output dropped.{spill_hint} ...]\n\n"
|
|
217
|
+
return head + marker + tail
|
|
218
|
+
|
|
219
|
+
# Default — middle-drop.
|
|
220
|
+
half = limit // 2
|
|
221
|
+
return (
|
|
222
|
+
result[:half]
|
|
223
|
+
+ f"\n\n[...{dropped} chars truncated.{spill_hint} ...]\n\n"
|
|
224
|
+
+ result[-half:]
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# COMPACT_KEEP_RECENT_TOOL_RESULTS / COMPACT_MIN_CONTENT_CHARS moved
|
|
229
|
+
# to constants.py during the T0.1 split; re-exported at the top of
|
|
230
|
+
# this module for back-compat.
|
|
231
|
+
|
|
232
|
+
# Only these tools' RESULTS are safe to age. They are REPLAYABLE
|
|
233
|
+
# OBSERVATIONS: their output is a snapshot of external state (the filesystem,
|
|
234
|
+
# a search, a shell command) that the model can regenerate on demand by
|
|
235
|
+
# re-running the exact same call. Aging them is lossless-in-effect — the model
|
|
236
|
+
# can always get them back. This mirrors claude-code's `COMPACTABLE_TOOLS` set
|
|
237
|
+
# (microCompact.ts) and opencode's prune (which protects non-replayable tool
|
|
238
|
+
# output). Deliberately EXCLUDED: write_file / edit_file / multi_edit /
|
|
239
|
+
# append_file. Their result is the DIFF of what the model just wrote — the only
|
|
240
|
+
# record in history of the change it made. Aging it away is what made the model
|
|
241
|
+
# "forget" what it wrote after a build failure and rewrite whole files from
|
|
242
|
+
# scratch (churn). Anything we can't attribute to a replayable tool is also left
|
|
243
|
+
# intact (conservative default).
|
|
244
|
+
_REPLAYABLE_TOOLS: frozenset[str] = frozenset({
|
|
245
|
+
"read_file", "grep", "glob", "list_files", "bash",
|
|
246
|
+
"web_search", "web_fetch",
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _tool_names_by_call_id(messages: list[dict]) -> dict[str, str]:
|
|
251
|
+
"""Map tool_call_id -> tool name by scanning assistant tool_calls, so a
|
|
252
|
+
tool-role result (which only carries `tool_call_id`) can be attributed to
|
|
253
|
+
the tool that produced it."""
|
|
254
|
+
names: dict[str, str] = {}
|
|
255
|
+
for m in messages:
|
|
256
|
+
if m.get("role") != "assistant":
|
|
257
|
+
continue
|
|
258
|
+
for tc in m.get("tool_calls") or []:
|
|
259
|
+
tc_id = tc.get("id")
|
|
260
|
+
name = ((tc.get("function") or {}).get("name") or "").strip()
|
|
261
|
+
if tc_id and name:
|
|
262
|
+
names[tc_id] = name
|
|
263
|
+
return names
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _result_tool_name(m: dict, id_to_name: dict[str, str]) -> str:
|
|
267
|
+
"""Best-effort tool name for a tool-role result: explicit `name` field if
|
|
268
|
+
present (some providers set it), else via the tool_call_id map."""
|
|
269
|
+
explicit = (m.get("name") or "").strip()
|
|
270
|
+
if explicit:
|
|
271
|
+
return explicit
|
|
272
|
+
return id_to_name.get(m.get("tool_call_id"), "")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _compact_old_tool_results(
|
|
276
|
+
messages: list[dict], keep_recent: int = COMPACT_KEEP_RECENT_TOOL_RESULTS
|
|
277
|
+
) -> list[dict]:
|
|
278
|
+
"""Return a copy of `messages` with older REPLAYABLE-OBSERVATION tool
|
|
279
|
+
results summarized.
|
|
280
|
+
|
|
281
|
+
Gated on `Feature.TOOL_RESULT_AGING` — when disabled the caller gets
|
|
282
|
+
the input list back unchanged, which is what eval uses to A/B
|
|
283
|
+
"how much does aging actually save per turn?"
|
|
284
|
+
|
|
285
|
+
We keep the last `keep_recent` tool results verbatim (the model usually
|
|
286
|
+
only needs the recent ones to decide the next step) and replace earlier
|
|
287
|
+
ones with a compact "[summarized ...]" placeholder — but ONLY for tools in
|
|
288
|
+
`_REPLAYABLE_TOOLS`. write_file/edit_file/multi_edit results (the diffs of
|
|
289
|
+
what the model wrote) are NEVER aged, so the model doesn't lose sight of
|
|
290
|
+
its own changes. `keep_recent` scales with the context window (more on a big
|
|
291
|
+
machine — see `_prepare_model_messages`), floored at 1 so aging never clears
|
|
292
|
+
the entire working set. User/assistant/system messages pass through
|
|
293
|
+
unchanged, and `tool_call_id` is preserved so the chat protocol still
|
|
294
|
+
reconciles ids correctly.
|
|
295
|
+
"""
|
|
296
|
+
from ..features import Feature, is_enabled
|
|
297
|
+
if not is_enabled(Feature.TOOL_RESULT_AGING):
|
|
298
|
+
return messages
|
|
299
|
+
# Floor at 1 so we never age away the entire working set of tool output.
|
|
300
|
+
keep_recent = max(1, keep_recent)
|
|
301
|
+
id_to_name = _tool_names_by_call_id(messages)
|
|
302
|
+
# Protect the LATEST read_file result for the few HOTTEST (most-recently-read)
|
|
303
|
+
# files from aging. read_file is "replayable", so aging its result out is
|
|
304
|
+
# cheap in theory — but in practice the model REPLAYS it (re-reads), and real
|
|
305
|
+
# traces showed ~68% of all reads were re-reads of a handful of actively-used
|
|
306
|
+
# files (App.tsx read 40× after being written). Keeping each hot file's
|
|
307
|
+
# current content in context (like write/edit diffs, which are never aged)
|
|
308
|
+
# makes the prompt's "don't re-read, it's already above" actually true.
|
|
309
|
+
# Bounded to _PROTECT_HOT_READS distinct paths so prefill/TTFT stays capped.
|
|
310
|
+
_PROTECT_HOT_READS = 5
|
|
311
|
+
_read_paths: dict[str, str] = {}
|
|
312
|
+
for _m in messages:
|
|
313
|
+
if _m.get("role") != "assistant":
|
|
314
|
+
continue
|
|
315
|
+
for _tc in _m.get("tool_calls") or []:
|
|
316
|
+
if ((_tc.get("function") or {}).get("name") or "").strip() != "read_file":
|
|
317
|
+
continue
|
|
318
|
+
_tcid = _tc.get("id")
|
|
319
|
+
if not _tcid:
|
|
320
|
+
continue
|
|
321
|
+
try:
|
|
322
|
+
_raw = (_tc.get("function") or {}).get("arguments") or "{}"
|
|
323
|
+
_a = _raw if isinstance(_raw, dict) else json.loads(_raw)
|
|
324
|
+
except Exception:
|
|
325
|
+
continue
|
|
326
|
+
_p = _a.get("path")
|
|
327
|
+
if isinstance(_p, str) and _p:
|
|
328
|
+
_read_paths[_tcid] = _p
|
|
329
|
+
_path_latest_idx: dict[str, int] = {}
|
|
330
|
+
for i, _m in enumerate(messages):
|
|
331
|
+
if _m.get("role") == "tool":
|
|
332
|
+
_p = _read_paths.get(_m.get("tool_call_id"))
|
|
333
|
+
if _p:
|
|
334
|
+
_path_latest_idx[_p] = i # highest index = most recent read
|
|
335
|
+
protected_read_idxs = {
|
|
336
|
+
idx for _p, idx in
|
|
337
|
+
sorted(_path_latest_idx.items(), key=lambda kv: -kv[1])[:_PROTECT_HOT_READS]
|
|
338
|
+
}
|
|
339
|
+
# Only REPLAYABLE-observation results are age-eligible; keep the last
|
|
340
|
+
# `keep_recent` of THOSE verbatim (counting non-replayable results toward
|
|
341
|
+
# the recency window would let a burst of writes push a still-needed read
|
|
342
|
+
# out of the kept set).
|
|
343
|
+
ageable_idxs = [
|
|
344
|
+
i for i, m in enumerate(messages)
|
|
345
|
+
if m.get("role") == "tool"
|
|
346
|
+
and _result_tool_name(m, id_to_name) in _REPLAYABLE_TOOLS
|
|
347
|
+
and i not in protected_read_idxs
|
|
348
|
+
]
|
|
349
|
+
if len(ageable_idxs) <= keep_recent:
|
|
350
|
+
return messages
|
|
351
|
+
|
|
352
|
+
cutoff_idx = ageable_idxs[-keep_recent]
|
|
353
|
+
out: list[dict] = []
|
|
354
|
+
for i, m in enumerate(messages):
|
|
355
|
+
if i >= cutoff_idx or m.get("role") != "tool":
|
|
356
|
+
out.append(m)
|
|
357
|
+
continue
|
|
358
|
+
if i in protected_read_idxs:
|
|
359
|
+
out.append(m) # hot file's current content — keep so it isn't re-read
|
|
360
|
+
continue
|
|
361
|
+
if _result_tool_name(m, id_to_name) not in _REPLAYABLE_TOOLS:
|
|
362
|
+
out.append(m) # non-replayable (write/edit diff, etc.) — preserve
|
|
363
|
+
continue
|
|
364
|
+
content = m.get("content") or ""
|
|
365
|
+
if not isinstance(content, str) or len(content) < COMPACT_MIN_CONTENT_CHARS:
|
|
366
|
+
out.append(m)
|
|
367
|
+
continue
|
|
368
|
+
summary = _semantic_tool_summary(content)
|
|
369
|
+
out.append({**m, "content": summary})
|
|
370
|
+
return out
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _semantic_tool_summary(content: str) -> str:
|
|
374
|
+
"""Compact old tool output by preserving operational facts first."""
|
|
375
|
+
lines = content.splitlines()
|
|
376
|
+
first_line = next((ln.strip()[:120] for ln in lines if ln.strip()), "")
|
|
377
|
+
facts = ""
|
|
378
|
+
if "[tool facts:" in content:
|
|
379
|
+
facts = content[content.find("[tool facts:"):].splitlines()[0][:300]
|
|
380
|
+
is_error = (
|
|
381
|
+
content.startswith("Error:")
|
|
382
|
+
or content.startswith("REJECTED:")
|
|
383
|
+
or content.startswith("[exit code ")
|
|
384
|
+
or "old_string not found" in content
|
|
385
|
+
or "Traceback " in content
|
|
386
|
+
)
|
|
387
|
+
if is_error:
|
|
388
|
+
# Self-conditioning (arXiv:2509.09677): echoing an OLD error's text
|
|
389
|
+
# pushes the model to repeat the mistake. Recent errors stay intact
|
|
390
|
+
# (kept above by keep_recent) so it can fix the immediate problem;
|
|
391
|
+
# older ones drop to a neutral note (the ledger still records the fact).
|
|
392
|
+
return "[an earlier tool call errored and was handled — details dropped]"
|
|
393
|
+
if facts:
|
|
394
|
+
return (
|
|
395
|
+
f"[older successful tool result summarized: {len(content)} chars, "
|
|
396
|
+
f"{len(lines)} lines]\n{facts}"
|
|
397
|
+
)
|
|
398
|
+
if any(token in content for token in ("http://localhost:", "127.0.0.1:", "PID:", "URL:")):
|
|
399
|
+
important = [
|
|
400
|
+
ln.strip()
|
|
401
|
+
for ln in lines
|
|
402
|
+
if any(token in ln for token in ("http://localhost:", "127.0.0.1:", "PID:", "URL:", "Log:"))
|
|
403
|
+
][:8]
|
|
404
|
+
return (
|
|
405
|
+
f"[older runtime result summarized: {len(content)} chars, "
|
|
406
|
+
f"{len(lines)} lines]\n" + "\n".join(important)
|
|
407
|
+
)
|
|
408
|
+
return (
|
|
409
|
+
f"[older tool result summarized: {first_line} … "
|
|
410
|
+
f"({len(content)} chars, {len(lines)} lines)]"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# REDACT_KEEP_RECENT_WRITES / REDACT_MIN_CONTENT_CHARS moved to
|
|
415
|
+
# constants.py during the T0.1 split; re-exported at the top of
|
|
416
|
+
# this module for back-compat.
|
|
417
|
+
|
|
418
|
+
def _redact_old_write_args(messages: list[dict]) -> list[dict]:
|
|
419
|
+
"""Pass-through: the model's OWN write/edit bodies are NEVER stripped.
|
|
420
|
+
|
|
421
|
+
History note (this used to strip them, and that was a bug)
|
|
422
|
+
----------------------------------------------------------
|
|
423
|
+
This pass previously replaced older write_file `content` (and edit_file /
|
|
424
|
+
multi_edit anchors) in the assistant tool_calls with a "[REDACTED — file is
|
|
425
|
+
on disk, call read_file to reload]" stub, keeping only the last
|
|
426
|
+
`REDACT_KEEP_RECENT_WRITES` verbatim. The intent was to cut context bloat.
|
|
427
|
+
|
|
428
|
+
The real cost (observed in logs): after a build failure the model could no
|
|
429
|
+
longer SEE what it had written a few rounds earlier — the body was a stub —
|
|
430
|
+
so instead of a targeted fix it rewrote the whole file from scratch, over
|
|
431
|
+
and over (churn). What the model wrote is exactly what it needs to reason
|
|
432
|
+
about its own change; that record must stay in history.
|
|
433
|
+
|
|
434
|
+
The reference agents agree: claude-code's microCompact clears old tool
|
|
435
|
+
RESULTS but never the write BODIES in assistant messages; codex/opencode
|
|
436
|
+
likewise summarize whole turns wholesale only when over budget rather than
|
|
437
|
+
surgically deleting the code the model wrote. So this function no longer
|
|
438
|
+
strips anything. Context is bounded instead by:
|
|
439
|
+
* `_compact_old_tool_results` — ages only REPLAYABLE observation results
|
|
440
|
+
(read/grep/glob/list_files/bash), never write/edit diffs;
|
|
441
|
+
* `_redact_duplicate_reads` — collapses re-reads of the same path;
|
|
442
|
+
* `_microcompact_for_prompt_budget` / `_compact_messages` — summarize old
|
|
443
|
+
turns wholesale when the prompt genuinely approaches the window budget.
|
|
444
|
+
|
|
445
|
+
Kept as a named no-op (rather than deleted) so callers, telemetry, and the
|
|
446
|
+
A/B feature flag keep working. Still gated on `Feature.WRITE_ARG_REDACTION`.
|
|
447
|
+
"""
|
|
448
|
+
from ..features import Feature, is_enabled
|
|
449
|
+
if not is_enabled(Feature.WRITE_ARG_REDACTION):
|
|
450
|
+
return messages
|
|
451
|
+
# Never strip write/edit bodies — see docstring. Older-write redaction is
|
|
452
|
+
# intentionally disabled; the empty target set makes the rest of this
|
|
453
|
+
# function a structural no-op while preserving its shape for back-compat.
|
|
454
|
+
REDACT_TOOLS: set[str] = set()
|
|
455
|
+
|
|
456
|
+
# Collect (outer_idx, tool_call_idx, tool_name) for every targeted call.
|
|
457
|
+
targets: list[tuple[int, int, str]] = []
|
|
458
|
+
for i, m in enumerate(messages):
|
|
459
|
+
if m.get("role") != "assistant":
|
|
460
|
+
continue
|
|
461
|
+
for j, tc in enumerate(m.get("tool_calls") or []):
|
|
462
|
+
name = ((tc.get("function") or {}).get("name") or "").strip()
|
|
463
|
+
if name in REDACT_TOOLS:
|
|
464
|
+
targets.append((i, j, name))
|
|
465
|
+
|
|
466
|
+
if len(targets) <= REDACT_KEEP_RECENT_WRITES:
|
|
467
|
+
return messages
|
|
468
|
+
|
|
469
|
+
# Everything except the last N is a redaction candidate.
|
|
470
|
+
to_redact = set(targets[:-REDACT_KEEP_RECENT_WRITES])
|
|
471
|
+
if not to_redact:
|
|
472
|
+
return messages
|
|
473
|
+
|
|
474
|
+
out: list[dict] = []
|
|
475
|
+
for i, m in enumerate(messages):
|
|
476
|
+
tcs = m.get("tool_calls") if m.get("role") == "assistant" else None
|
|
477
|
+
if not tcs:
|
|
478
|
+
out.append(m)
|
|
479
|
+
continue
|
|
480
|
+
new_tcs = []
|
|
481
|
+
touched = False
|
|
482
|
+
for j, tc in enumerate(tcs):
|
|
483
|
+
name = ((tc.get("function") or {}).get("name") or "").strip()
|
|
484
|
+
if (i, j, name) not in to_redact:
|
|
485
|
+
new_tcs.append(tc)
|
|
486
|
+
continue
|
|
487
|
+
# Redact: parse args, strip the bulky field, re-serialise.
|
|
488
|
+
try:
|
|
489
|
+
raw_args = (tc.get("function") or {}).get("arguments") or "{}"
|
|
490
|
+
args = json.loads(raw_args) if isinstance(raw_args, str) else dict(raw_args)
|
|
491
|
+
except Exception:
|
|
492
|
+
new_tcs.append(tc)
|
|
493
|
+
continue
|
|
494
|
+
path = args.get("path") or "?"
|
|
495
|
+
size_hint = ""
|
|
496
|
+
if name in {"write_file", "append_file"}:
|
|
497
|
+
content = args.get("content") or ""
|
|
498
|
+
if isinstance(content, str) and len(content) >= REDACT_MIN_CONTENT_CHARS:
|
|
499
|
+
# DROP the `content` key entirely instead of replacing
|
|
500
|
+
# it with a stub string. Earlier we put a "[REDACTED
|
|
501
|
+
# from history — …]" sentinel into `args["content"]`
|
|
502
|
+
# to inform the model the file existed but the bytes
|
|
503
|
+
# were elided. IQ2_M Qwen, on the next round, would
|
|
504
|
+
# SEE that string in its own past tool_call and copy
|
|
505
|
+
# it verbatim as the `content` arg of a NEW write_file
|
|
506
|
+
# call — overwriting the real file on disk with the
|
|
507
|
+
# stub itself. Dropping the
|
|
508
|
+
# key means there's nothing to copy. The synthetic
|
|
509
|
+
# `_redaction_note` sibling tells the model what
|
|
510
|
+
# happened without exposing a copyable `content`.
|
|
511
|
+
size_hint = f" ({len(content)} chars, {content.count(chr(10)) + 1} lines)"
|
|
512
|
+
args.pop("content", None)
|
|
513
|
+
args["_redaction_note"] = (
|
|
514
|
+
f"content elided from history{size_hint}. "
|
|
515
|
+
f"file is on disk at {path}. "
|
|
516
|
+
f"call read_file path={path!r} to reload."
|
|
517
|
+
)
|
|
518
|
+
touched = True
|
|
519
|
+
elif name == "edit_file":
|
|
520
|
+
old_s = args.get("old_string") or ""
|
|
521
|
+
new_s = args.get("new_string") or ""
|
|
522
|
+
if isinstance(new_s, str) and len(new_s) + len(old_s) >= REDACT_MIN_CONTENT_CHARS:
|
|
523
|
+
size_hint = f" ({len(new_s)} chars new, {len(old_s)} chars old)"
|
|
524
|
+
args["old_string"] = f"[REDACTED{size_hint}]"
|
|
525
|
+
args["new_string"] = (
|
|
526
|
+
f"[REDACTED — edit applied to {path}. "
|
|
527
|
+
f"Call read_file with path={path!r} to see current content.]"
|
|
528
|
+
)
|
|
529
|
+
touched = True
|
|
530
|
+
elif name == "multi_edit":
|
|
531
|
+
edits = args.get("edits") or []
|
|
532
|
+
if isinstance(edits, list) and edits:
|
|
533
|
+
total = sum(
|
|
534
|
+
len((e or {}).get("new_string") or "")
|
|
535
|
+
+ len((e or {}).get("old_string") or "")
|
|
536
|
+
for e in edits
|
|
537
|
+
)
|
|
538
|
+
if total >= REDACT_MIN_CONTENT_CHARS:
|
|
539
|
+
args["edits"] = [
|
|
540
|
+
{"old_string": f"[REDACTED edit {k+1}/{len(edits)}]",
|
|
541
|
+
"new_string": f"[REDACTED — {len(edits)} edits applied to {path}. "
|
|
542
|
+
f"Call read_file with path={path!r} to see current content.]"}
|
|
543
|
+
for k in range(len(edits))
|
|
544
|
+
]
|
|
545
|
+
touched = True
|
|
546
|
+
if touched:
|
|
547
|
+
# Rebuild the tool_call with the redacted arguments.
|
|
548
|
+
new_fn = {**(tc.get("function") or {}), "arguments": json.dumps(args)}
|
|
549
|
+
new_tcs.append({**tc, "function": new_fn})
|
|
550
|
+
else:
|
|
551
|
+
new_tcs.append(tc)
|
|
552
|
+
out.append({**m, "tool_calls": new_tcs})
|
|
553
|
+
return out
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# READ_UNCHANGED_STUB_PREFIX moved to constants.py during the T0.1
|
|
557
|
+
# split; re-exported at the top of this module for back-compat.
|
|
558
|
+
|
|
559
|
+
def _redact_duplicate_reads(messages: list[dict]) -> list[dict]:
|
|
560
|
+
"""Return a copy of `messages` with older duplicate read_file results stubbed.
|
|
561
|
+
|
|
562
|
+
Walks the message list once. For each read_file tool_use -> tool_result
|
|
563
|
+
pair, tracks which paths have been read. The LAST read for each path
|
|
564
|
+
keeps its full result; earlier ones get replaced with
|
|
565
|
+
`READ_UNCHANGED_STUB_PREFIX`. The tool_call itself (in the assistant
|
|
566
|
+
message) is untouched — only the result content shrinks.
|
|
567
|
+
|
|
568
|
+
This runs alongside `_redact_old_write_args` and
|
|
569
|
+
`_compact_old_tool_results`. The three are complementary:
|
|
570
|
+
• write-arg redaction shrinks assistant → tool_calls payloads
|
|
571
|
+
• duplicate-read stubbing shrinks tool role results for read_file
|
|
572
|
+
• tool-result aging shrinks tool role results for everything else
|
|
573
|
+
|
|
574
|
+
Gated on `Feature.DUPLICATE_READ_STUB`. Disabled returns the input
|
|
575
|
+
unchanged.
|
|
576
|
+
"""
|
|
577
|
+
from ..features import Feature, is_enabled
|
|
578
|
+
if not is_enabled(Feature.DUPLICATE_READ_STUB):
|
|
579
|
+
return messages
|
|
580
|
+
# First pass: build map from tool_call_id → (path, outer_idx) for
|
|
581
|
+
# every read_file call. We need the id to match it against the
|
|
582
|
+
# corresponding tool-role result message later.
|
|
583
|
+
read_calls: dict[str, str] = {} # id → path
|
|
584
|
+
for m in messages:
|
|
585
|
+
if m.get("role") != "assistant":
|
|
586
|
+
continue
|
|
587
|
+
for tc in m.get("tool_calls") or []:
|
|
588
|
+
name = ((tc.get("function") or {}).get("name") or "").strip()
|
|
589
|
+
if name != "read_file":
|
|
590
|
+
continue
|
|
591
|
+
tc_id = tc.get("id")
|
|
592
|
+
if not tc_id:
|
|
593
|
+
continue
|
|
594
|
+
try:
|
|
595
|
+
_raw = (tc.get("function") or {}).get("arguments") or "{}"
|
|
596
|
+
args = _raw if isinstance(_raw, dict) else json.loads(_raw)
|
|
597
|
+
except Exception:
|
|
598
|
+
continue
|
|
599
|
+
path = args.get("path")
|
|
600
|
+
if isinstance(path, str) and path:
|
|
601
|
+
read_calls[tc_id] = path
|
|
602
|
+
|
|
603
|
+
if not read_calls:
|
|
604
|
+
return messages
|
|
605
|
+
|
|
606
|
+
# Second pass: find the tool-role result index for each read_file call
|
|
607
|
+
# and group by path. Keep the highest-index (most recent) per path;
|
|
608
|
+
# stub everything else for the same path.
|
|
609
|
+
path_to_result_idxs: dict[str, list[int]] = {}
|
|
610
|
+
for i, m in enumerate(messages):
|
|
611
|
+
if m.get("role") != "tool":
|
|
612
|
+
continue
|
|
613
|
+
tc_id = m.get("tool_call_id")
|
|
614
|
+
if tc_id in read_calls:
|
|
615
|
+
path_to_result_idxs.setdefault(read_calls[tc_id], []).append(i)
|
|
616
|
+
|
|
617
|
+
# Any path with 2+ reads is a redaction candidate.
|
|
618
|
+
to_stub: set[int] = set()
|
|
619
|
+
for path, idxs in path_to_result_idxs.items():
|
|
620
|
+
if len(idxs) >= 2:
|
|
621
|
+
# Keep the last; stub all earlier ones.
|
|
622
|
+
to_stub.update(idxs[:-1])
|
|
623
|
+
|
|
624
|
+
if not to_stub:
|
|
625
|
+
return messages
|
|
626
|
+
|
|
627
|
+
out: list[dict] = []
|
|
628
|
+
for i, m in enumerate(messages):
|
|
629
|
+
if i not in to_stub:
|
|
630
|
+
out.append(m)
|
|
631
|
+
continue
|
|
632
|
+
content = m.get("content") or ""
|
|
633
|
+
if not isinstance(content, str) or len(content) < REDACT_MIN_CONTENT_CHARS:
|
|
634
|
+
out.append(m)
|
|
635
|
+
continue
|
|
636
|
+
out.append({**m, "content": READ_UNCHANGED_STUB_PREFIX})
|
|
637
|
+
return out
|
|
638
|
+
|
|
639
|
+
def _msg_bytes(messages: list[dict]) -> int:
|
|
640
|
+
"""Approximate serialized size of `messages` for telemetry.
|
|
641
|
+
|
|
642
|
+
Uses `json.dumps` so we count tool_call argument payloads and
|
|
643
|
+
tool_result content the same way the API client will. Cheap enough
|
|
644
|
+
to call once per round; not exact-token-count, but proportional
|
|
645
|
+
to wire size which is what we actually care about.
|
|
646
|
+
"""
|
|
647
|
+
try:
|
|
648
|
+
return len(json.dumps(messages))
|
|
649
|
+
except Exception:
|
|
650
|
+
return 0
|
|
651
|
+
|
|
652
|
+
def _window_aware_compaction(ctx_window_chars: int | None) -> tuple[int, int]:
|
|
653
|
+
"""(history_budget_bytes, keep_recent_tool_results) scaled to the context
|
|
654
|
+
window. Capacity and replay latency are separate budgets: large-RAM Macs
|
|
655
|
+
can retain richer recent evidence, but hybrid/recurrent local models may
|
|
656
|
+
still re-process the entire prompt each round. Durable ledgers preserve
|
|
657
|
+
completed work while this hot transcript remains bounded.
|
|
658
|
+
|
|
659
|
+
`ctx_window_chars` ≈ num_ctx tokens × ~3.5 chars/token. None/unknown →
|
|
660
|
+
the legacy fixed behaviour (back-compat, safe on tiny windows).
|
|
661
|
+
"""
|
|
662
|
+
if not ctx_window_chars or ctx_window_chars <= 0:
|
|
663
|
+
return 36_000, COMPACT_KEEP_RECENT_TOOL_RESULTS
|
|
664
|
+
# A large KV window is capacity, not a latency target. Live 128 GB / Qwen
|
|
665
|
+
# traces showed TTFT rising from ~2 s to 44 s as one build replayed ~28K
|
|
666
|
+
# prompt tokens. Cap the hot transcript while the durable progress ledger
|
|
667
|
+
# and filesystem-state block preserve what was completed.
|
|
668
|
+
budget = max(36_000, min(48_000, int(ctx_window_chars * 0.20)))
|
|
669
|
+
keep = max(COMPACT_KEEP_RECENT_TOOL_RESULTS, min(8, ctx_window_chars // 40_000))
|
|
670
|
+
return budget, keep
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def latency_budgeted_hot_replay(
|
|
674
|
+
ctx_window_chars: int | None,
|
|
675
|
+
observed_ttft_ms: int | None,
|
|
676
|
+
*,
|
|
677
|
+
target_ttft_ms: int = 8_000,
|
|
678
|
+
) -> tuple[int, int]:
|
|
679
|
+
"""Adapt hot replay to measured local prefill latency, not KV capacity."""
|
|
680
|
+
budget, keep = _window_aware_compaction(ctx_window_chars)
|
|
681
|
+
if not observed_ttft_ms or observed_ttft_ms <= target_ttft_ms:
|
|
682
|
+
return budget, keep
|
|
683
|
+
ratio = max(0.35, target_ttft_ms / observed_ttft_ms)
|
|
684
|
+
return max(18_000, int(budget * ratio)), max(2, int(keep * ratio))
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _prepare_model_messages(
|
|
688
|
+
messages: list[dict], ctx_window_chars: int | None = None,
|
|
689
|
+
observed_ttft_ms: int | None = None,
|
|
690
|
+
) -> list[dict]:
|
|
691
|
+
"""One-stop context-shrink pass before sending to the model.
|
|
692
|
+
|
|
693
|
+
`ctx_window_chars` (the model's context window in chars, RAM-derived) makes
|
|
694
|
+
compaction window-aware: big machines keep far more history, small ones
|
|
695
|
+
stay aggressive. None preserves the legacy fixed budget.
|
|
696
|
+
|
|
697
|
+
Composes the three reduction passes in a fixed order:
|
|
698
|
+
1. `_redact_old_write_args` — strip bulky content from old
|
|
699
|
+
write_file / edit_file / multi_edit tool_call arguments.
|
|
700
|
+
2. `_redact_duplicate_reads` — collapse older read_file results
|
|
701
|
+
for the same path to a stub pointing at the newer read.
|
|
702
|
+
3. `_compact_old_tool_results` — summarize old tool-role results
|
|
703
|
+
for every other tool (bash, grep, etc.).
|
|
704
|
+
All three return copies, so the original `messages` list is
|
|
705
|
+
untouched. Order matters: (1) and (2) each shrink different parts
|
|
706
|
+
of the history, and (3) catches whatever's left from (1) and (2)
|
|
707
|
+
plus unrelated tools.
|
|
708
|
+
|
|
709
|
+
Telemetry: when any pass shrinks the message list, we append a
|
|
710
|
+
`redaction` line to ~/.localcode/lifecycle.log with per-pass byte
|
|
711
|
+
deltas. Lets us answer "how much is each layer actually saving in
|
|
712
|
+
practice?" without instrumenting the agent loop. Silent on no-op
|
|
713
|
+
so the log doesn't fill up with empty events.
|
|
714
|
+
"""
|
|
715
|
+
budget_bytes, keep_recent = latency_budgeted_hot_replay(ctx_window_chars, observed_ttft_ms)
|
|
716
|
+
before = _msg_bytes(messages)
|
|
717
|
+
after_writes = _redact_old_write_args(messages)
|
|
718
|
+
bytes_writes = before - _msg_bytes(after_writes)
|
|
719
|
+
# RAM-tier the duplicate-read stubbing: on a big window (≥128K chars ≈
|
|
720
|
+
# 36K tokens, i.e. 48 GB+) there's room to KEEP prior reads so the model
|
|
721
|
+
# retains what it saw (less "re-read because it scrolled away"). Only stub
|
|
722
|
+
# duplicate reads on smaller windows where the space is actually needed.
|
|
723
|
+
# The progress ledger covers the small-window case where reads must be
|
|
724
|
+
# dropped. Codex similarly re-hydrates rather than aggressively stubbing.
|
|
725
|
+
if ctx_window_chars and ctx_window_chars >= 128_000:
|
|
726
|
+
after_reads = after_writes
|
|
727
|
+
else:
|
|
728
|
+
after_reads = _redact_duplicate_reads(after_writes)
|
|
729
|
+
bytes_reads = _msg_bytes(after_writes) - _msg_bytes(after_reads)
|
|
730
|
+
after_tools = _compact_old_tool_results(after_reads, keep_recent=keep_recent)
|
|
731
|
+
after_budget = _microcompact_for_prompt_budget(after_tools, target_bytes=budget_bytes)
|
|
732
|
+
bytes_tools = _msg_bytes(after_reads) - _msg_bytes(after_tools)
|
|
733
|
+
bytes_budget = _msg_bytes(after_tools) - _msg_bytes(after_budget)
|
|
734
|
+
total_saved = bytes_writes + bytes_reads + bytes_tools + bytes_budget
|
|
735
|
+
if total_saved > 0:
|
|
736
|
+
try:
|
|
737
|
+
from ..server_manager import _lifecycle_log
|
|
738
|
+
_lifecycle_log(
|
|
739
|
+
"redaction",
|
|
740
|
+
msgs=len(messages),
|
|
741
|
+
bytes_before=before,
|
|
742
|
+
bytes_after=_msg_bytes(after_budget),
|
|
743
|
+
saved_writes=bytes_writes,
|
|
744
|
+
saved_reads=bytes_reads,
|
|
745
|
+
saved_tools=bytes_tools,
|
|
746
|
+
saved_budget=bytes_budget,
|
|
747
|
+
saved_total=total_saved,
|
|
748
|
+
pct_saved=int(total_saved * 100 / max(1, before)),
|
|
749
|
+
)
|
|
750
|
+
except Exception:
|
|
751
|
+
pass
|
|
752
|
+
return after_budget
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _microcompact_for_prompt_budget(messages: list[dict], *, target_bytes: int = 36_000) -> list[dict]:
|
|
756
|
+
"""Deterministically summarize older protocol-safe history.
|
|
757
|
+
|
|
758
|
+
The per-tool aging passes shrink payloads, but long turns can still
|
|
759
|
+
accumulate dozens of small assistant/tool pairs plus recent write args.
|
|
760
|
+
Keep a compact task ledger and recent messages instead of every
|
|
761
|
+
intermediate tool transcript. This pass keeps the system message and a
|
|
762
|
+
protocol-safe recent suffix, replacing the older middle with a factual
|
|
763
|
+
summary of user requests, files touched, commands, and errors. It
|
|
764
|
+
never mutates the source session history.
|
|
765
|
+
"""
|
|
766
|
+
if _msg_bytes(messages) <= target_bytes or len(messages) <= 14:
|
|
767
|
+
return messages
|
|
768
|
+
|
|
769
|
+
system_count = 1 if messages and messages[0].get("role") == "system" else 0
|
|
770
|
+
# NEVER compact away the task statement. The first real user message (not
|
|
771
|
+
# a synthetic "SYSTEM:" nudge) anchors the entire turn: summarizing it into
|
|
772
|
+
# a one-line ledger made the model literally ask "what is the actual task?"
|
|
773
|
+
# mid-build and re-derive the goal from directory names (observed live on a
|
|
774
|
+
# long agentic build). Keep it VERBATIM at the head, always — the reference
|
|
775
|
+
# harnesses' compaction likewise carries the original request forward.
|
|
776
|
+
head_count = system_count
|
|
777
|
+
for i in range(system_count, len(messages)):
|
|
778
|
+
m = messages[i]
|
|
779
|
+
if m.get("role") == "user" and isinstance(m.get("content"), str) \
|
|
780
|
+
and not m["content"].startswith("SYSTEM:"):
|
|
781
|
+
head_count = i + 1
|
|
782
|
+
break
|
|
783
|
+
best = messages
|
|
784
|
+
# Tighten the protocol-safe suffix until the serialized prompt fits. A
|
|
785
|
+
# fixed 12-message tail can itself contain several complete source files.
|
|
786
|
+
for tail_size in (12, 10, 8, 6, 4):
|
|
787
|
+
boundary = max(head_count, len(messages) - tail_size)
|
|
788
|
+
while boundary > head_count:
|
|
789
|
+
if messages[boundary].get("role") == "tool":
|
|
790
|
+
boundary -= 1
|
|
791
|
+
continue
|
|
792
|
+
prev = messages[boundary - 1]
|
|
793
|
+
if prev.get("role") == "assistant" and prev.get("tool_calls"):
|
|
794
|
+
boundary -= 1
|
|
795
|
+
continue
|
|
796
|
+
break
|
|
797
|
+
if boundary <= head_count:
|
|
798
|
+
continue
|
|
799
|
+
old = messages[head_count:boundary]
|
|
800
|
+
recent = messages[boundary:]
|
|
801
|
+
summary = _compact_history_summary(old)
|
|
802
|
+
candidate = [
|
|
803
|
+
*messages[:head_count],
|
|
804
|
+
{"role": "user", "content": summary},
|
|
805
|
+
{"role": "assistant", "content": "Continuing with the summarized prior work."},
|
|
806
|
+
*recent,
|
|
807
|
+
]
|
|
808
|
+
if _msg_bytes(candidate) < _msg_bytes(best):
|
|
809
|
+
best = candidate
|
|
810
|
+
if _msg_bytes(candidate) <= target_bytes:
|
|
811
|
+
return candidate
|
|
812
|
+
return best
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _compact_history_summary(messages: list[dict]) -> str:
|
|
816
|
+
files: list[str] = []
|
|
817
|
+
commands: list[str] = []
|
|
818
|
+
errors: list[str] = []
|
|
819
|
+
users: list[str] = []
|
|
820
|
+
actions: list[str] = []
|
|
821
|
+
|
|
822
|
+
def _add_unique(items: list[str], value: str, limit: int) -> None:
|
|
823
|
+
value = value.strip()
|
|
824
|
+
if value and value not in items:
|
|
825
|
+
items.append(value[:180])
|
|
826
|
+
del items[:-limit]
|
|
827
|
+
|
|
828
|
+
for m in messages:
|
|
829
|
+
role = m.get("role", "")
|
|
830
|
+
content = str(m.get("content") or "")
|
|
831
|
+
if role == "user" and content:
|
|
832
|
+
_add_unique(users, content.replace("\n", " "), 4)
|
|
833
|
+
if role == "tool" and content:
|
|
834
|
+
if (
|
|
835
|
+
content.startswith("Error:")
|
|
836
|
+
or content.startswith("REJECTED:")
|
|
837
|
+
or content.startswith("[exit code ")
|
|
838
|
+
or "Traceback " in content
|
|
839
|
+
or "old_string not found" in content
|
|
840
|
+
):
|
|
841
|
+
_add_unique(errors, _semantic_tool_summary(content).replace("\n", " "), 6)
|
|
842
|
+
if role != "assistant":
|
|
843
|
+
continue
|
|
844
|
+
for tc in m.get("tool_calls") or []:
|
|
845
|
+
fn = tc.get("function") or {}
|
|
846
|
+
name = str(fn.get("name") or "").strip()
|
|
847
|
+
try:
|
|
848
|
+
_raw = fn.get("arguments") or "{}"
|
|
849
|
+
args = _raw if isinstance(_raw, dict) else json.loads(_raw)
|
|
850
|
+
except Exception:
|
|
851
|
+
args = {}
|
|
852
|
+
path = args.get("path") or args.get("file_path")
|
|
853
|
+
if isinstance(path, str) and path:
|
|
854
|
+
_add_unique(files, path, 12)
|
|
855
|
+
if name == "bash":
|
|
856
|
+
cmd = str(args.get("command") or "")
|
|
857
|
+
_add_unique(commands, cmd.replace("\n", " "), 8)
|
|
858
|
+
if name:
|
|
859
|
+
actions.append(f"{name}({_summarize_args(args)})")
|
|
860
|
+
del actions[:-10]
|
|
861
|
+
|
|
862
|
+
lines = ["Earlier context summarized to control prompt size:"]
|
|
863
|
+
if users:
|
|
864
|
+
lines.append("Recent user intent: " + " | ".join(users[-3:]))
|
|
865
|
+
if files:
|
|
866
|
+
lines.append("Files touched/read: " + ", ".join(files[-10:]))
|
|
867
|
+
if commands:
|
|
868
|
+
lines.append("Commands run: " + "; ".join(commands[-5:]))
|
|
869
|
+
if errors:
|
|
870
|
+
lines.append("Errors/fixes to preserve: " + " | ".join(errors[-4:]))
|
|
871
|
+
if actions:
|
|
872
|
+
lines.append("Recent prior tool actions: " + "; ".join(actions[-8:]))
|
|
873
|
+
lines.append("If exact file content is needed, call read_file on the relevant path.")
|
|
874
|
+
return "\n".join(lines)
|
|
875
|
+
|
|
876
|
+
def build_progress_ledger(
|
|
877
|
+
changed_files: list[str],
|
|
878
|
+
bash_history: list[tuple[str, str]],
|
|
879
|
+
files_read: list[str],
|
|
880
|
+
budget_chars: int,
|
|
881
|
+
) -> str:
|
|
882
|
+
"""A compact, always-current 'what I've already done this task' ledger.
|
|
883
|
+
|
|
884
|
+
Ported from Codex's design (handoff summary + tool-state awareness + an
|
|
885
|
+
explicit 'build on this, don't duplicate work' instruction). Built from the
|
|
886
|
+
loop's DURABLE state (changed_files / bash_history / files_read) — NOT the
|
|
887
|
+
message history — so it survives compaction: on a small-RAM machine the raw
|
|
888
|
+
tool results get compacted to placeholders, but this ledger persists, which
|
|
889
|
+
is exactly what stops the model re-reading files and "starting from scratch."
|
|
890
|
+
|
|
891
|
+
`budget_chars` is window-scaled (see model_config.progress_ledger_budget_chars):
|
|
892
|
+
compact on a 16 GB Mac, richer on a 128 GB Mac. Returns "" when nothing has
|
|
893
|
+
happened yet (keeps the prompt prefix stable on the first round).
|
|
894
|
+
"""
|
|
895
|
+
if not (changed_files or bash_history or files_read):
|
|
896
|
+
return ""
|
|
897
|
+
|
|
898
|
+
def _uniq(seq: list[str]) -> list[str]:
|
|
899
|
+
return list(dict.fromkeys(s for s in seq if s))
|
|
900
|
+
|
|
901
|
+
lines = [
|
|
902
|
+
"## A log of YOUR OWN tool calls so far this turn — NOT the user's work "
|
|
903
|
+
"and NOT pre-existing files on disk (leftover files you did not create are "
|
|
904
|
+
"not your progress or your task). Don't re-read/re-run anything below "
|
|
905
|
+
"unless you changed it; keep making NEW progress toward the user's goal.",
|
|
906
|
+
]
|
|
907
|
+
# Files created/edited is the most important line (it's "where I am" in a
|
|
908
|
+
# multi-file build) — list it FIRST and show ALL of them, not just the last
|
|
909
|
+
# 12. Capping at 12 dropped the earliest files once a build exceeded 12,
|
|
910
|
+
# so the model re-checked what existed and felt like it restarted. Paths are
|
|
911
|
+
# short; the whole list is cheap and is the anchor that prevents forgetting.
|
|
912
|
+
cf = _uniq(changed_files)
|
|
913
|
+
# The created/edited list is PROTECTED — never truncated by the budget.
|
|
914
|
+
# It's the anchor that stops the model forgetting what it built; losing any
|
|
915
|
+
# of it is what caused the "restarting" feeling. Everything ELSE (files
|
|
916
|
+
# read, commands) shares the remaining budget and can be trimmed.
|
|
917
|
+
protected = []
|
|
918
|
+
if cf:
|
|
919
|
+
protected.append(f"Files YOU already created/edited this task ({len(cf)}) — "
|
|
920
|
+
"do NOT recreate or re-check these, build on them: " + ", ".join(cf))
|
|
921
|
+
optional = []
|
|
922
|
+
rd = _uniq(files_read)
|
|
923
|
+
if rd:
|
|
924
|
+
optional.append("Files read: " + ", ".join(rd[-12:]))
|
|
925
|
+
if bash_history:
|
|
926
|
+
cmds: list[str] = []
|
|
927
|
+
for cmd, res in bash_history[-8:]:
|
|
928
|
+
r = str(res)
|
|
929
|
+
bad = (
|
|
930
|
+
r.startswith("[exit code ") or r.startswith("Error:")
|
|
931
|
+
or r.startswith("REJECTED:") or "Traceback " in r
|
|
932
|
+
)
|
|
933
|
+
cmds.append(f"{'x' if bad else 'ok'} {str(cmd).strip().splitlines()[0][:70]}"
|
|
934
|
+
if str(cmd).strip() else "")
|
|
935
|
+
cmds = [c for c in cmds if c]
|
|
936
|
+
if cmds:
|
|
937
|
+
optional.append("Commands run: " + " | ".join(cmds))
|
|
938
|
+
|
|
939
|
+
head = "\n".join(lines + protected)
|
|
940
|
+
if not optional:
|
|
941
|
+
return head
|
|
942
|
+
extra = "\n".join(optional)
|
|
943
|
+
if budget_chars and len(head) + 1 + len(extra) > budget_chars:
|
|
944
|
+
room = max(0, budget_chars - len(head) - 3)
|
|
945
|
+
extra = extra[:room].rstrip() + " …" if room > 0 else ""
|
|
946
|
+
return head + ("\n" + extra if extra else "")
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
_PROJECT_MARKERS = ("package.json", "pyproject.toml", ".git", "go.mod", "Cargo.toml", "tsconfig.json")
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _project_root_for(common: str, dirs: list[str]) -> str | None:
|
|
953
|
+
"""The project directory to scan: the nearest marker at-or-below `common`,
|
|
954
|
+
else the deepest single changed-file dir. Bounds the walk to one project."""
|
|
955
|
+
import os
|
|
956
|
+
# Walk DOWN from each changed dir toward `common` looking for a marker,
|
|
957
|
+
# picking the shallowest marker that is still within `common`.
|
|
958
|
+
candidates = []
|
|
959
|
+
for d in dirs:
|
|
960
|
+
cur = d
|
|
961
|
+
while cur and cur.startswith(common):
|
|
962
|
+
if any(os.path.exists(os.path.join(cur, m)) for m in _PROJECT_MARKERS):
|
|
963
|
+
candidates.append(cur)
|
|
964
|
+
parent = os.path.dirname(cur)
|
|
965
|
+
if parent == cur:
|
|
966
|
+
break
|
|
967
|
+
cur = parent
|
|
968
|
+
if candidates:
|
|
969
|
+
return min(candidates, key=len) # shallowest marker dir
|
|
970
|
+
# No marker: only trust it if all changed files share ONE dir (a real
|
|
971
|
+
# single-project build); otherwise decline (common ancestor may be broad).
|
|
972
|
+
return dirs[0] if len(set(dirs)) == 1 else None
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def build_filesystem_state(changed_files: list[str], max_files: int = 80) -> str:
|
|
976
|
+
"""Ground-truth 'what's actually on disk' for the project being built.
|
|
977
|
+
|
|
978
|
+
The #1 fix for the model re-checking which files exist and re-creating
|
|
979
|
+
them (pi/codex/opencode all converge on this): don't rely on the model's
|
|
980
|
+
recollection — reconcile against the FILESYSTEM in code, each round. We
|
|
981
|
+
walk the tree rooted at the project the model is building (the common
|
|
982
|
+
parent of the files it has changed) and list the real source files. Because
|
|
983
|
+
the truth comes from the OS, it survives compaction/restart and the model
|
|
984
|
+
can't hallucinate it away. Returns "" when nothing has been built yet.
|
|
985
|
+
"""
|
|
986
|
+
import os
|
|
987
|
+
roots = [os.path.dirname(f) for f in changed_files if f]
|
|
988
|
+
if not roots:
|
|
989
|
+
return ""
|
|
990
|
+
try:
|
|
991
|
+
common = os.path.commonpath([os.path.abspath(os.path.dirname(f))
|
|
992
|
+
for f in changed_files if f])
|
|
993
|
+
except Exception:
|
|
994
|
+
common = os.path.abspath(roots[0])
|
|
995
|
+
# SCOPE GUARD: only ever walk a real PROJECT dir, never a broad ancestor
|
|
996
|
+
# like ~ or a top-level workspace. Anchor to the nearest project marker
|
|
997
|
+
# (package.json / pyproject.toml / .git / go.mod / Cargo.toml) at-or-below
|
|
998
|
+
# the common path; if none, use the deepest single changed-file dir. And
|
|
999
|
+
# NEVER walk the home dir or anything shallower — bail out entirely.
|
|
1000
|
+
root = _project_root_for(common, [os.path.abspath(os.path.dirname(f))
|
|
1001
|
+
for f in changed_files if f])
|
|
1002
|
+
home = os.path.expanduser("~")
|
|
1003
|
+
if not root or not os.path.isdir(root):
|
|
1004
|
+
return ""
|
|
1005
|
+
if os.path.abspath(root) in (home, os.path.dirname(home), os.path.sep):
|
|
1006
|
+
return ""
|
|
1007
|
+
if len(os.path.abspath(root).rstrip("/").split("/")) < 4: # too shallow → unsafe to walk
|
|
1008
|
+
return ""
|
|
1009
|
+
_SKIP = {".git", "node_modules", "dist", "build", ".venv", "__pycache__", ".next"}
|
|
1010
|
+
_SRC = (".ts", ".tsx", ".js", ".jsx", ".py", ".css", ".scss", ".html", ".json",
|
|
1011
|
+
".md", ".go", ".rs", ".java", ".rb", ".php", ".vue", ".svelte", ".toml", ".yaml", ".yml")
|
|
1012
|
+
found: list[str] = []
|
|
1013
|
+
for base, dirs, files in os.walk(root):
|
|
1014
|
+
dirs[:] = [d for d in dirs if d not in _SKIP]
|
|
1015
|
+
for fn in files:
|
|
1016
|
+
if fn.endswith(_SRC):
|
|
1017
|
+
rel = os.path.relpath(os.path.join(base, fn), root)
|
|
1018
|
+
found.append(rel)
|
|
1019
|
+
if len(found) >= max_files:
|
|
1020
|
+
break
|
|
1021
|
+
if len(found) >= max_files:
|
|
1022
|
+
break
|
|
1023
|
+
if not found:
|
|
1024
|
+
return ""
|
|
1025
|
+
found.sort()
|
|
1026
|
+
return (
|
|
1027
|
+
f"## Files that ALREADY EXIST on disk in this project ({os.path.basename(root)}/) "
|
|
1028
|
+
f"— ground truth, {len(found)} file(s). Do NOT list the directory to check; "
|
|
1029
|
+
"do NOT recreate these. Edit an existing file or create a NEW one the task needs:\n"
|
|
1030
|
+
+ ", ".join(found)
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _estimate_tokens(messages: list[dict]) -> int:
|
|
1035
|
+
"""Rough token estimate: chars / 4."""
|
|
1036
|
+
total = 0
|
|
1037
|
+
for m in messages:
|
|
1038
|
+
total += len(str(m.get("content", "")))
|
|
1039
|
+
for tc in m.get("tool_calls", []):
|
|
1040
|
+
total += len(str(tc.get("function", {}).get("arguments", "")))
|
|
1041
|
+
return total // 4
|
|
1042
|
+
|
|
1043
|
+
def _compact_messages(messages: list[dict], out: "OutputManager") -> list[dict]:
|
|
1044
|
+
"""Summarize old messages, keep recent context.
|
|
1045
|
+
|
|
1046
|
+
The split must never orphan a `tool` message (server returns 400 if a tool
|
|
1047
|
+
response has no preceding assistant/tool_calls in the message list). Walk
|
|
1048
|
+
the boundary back until both halves are self-contained.
|
|
1049
|
+
"""
|
|
1050
|
+
if len(messages) <= 12:
|
|
1051
|
+
return messages
|
|
1052
|
+
|
|
1053
|
+
system = [m for m in messages[:2] if m.get("role") == "system"]
|
|
1054
|
+
sys_len = len(system)
|
|
1055
|
+
|
|
1056
|
+
boundary = max(sys_len, len(messages) - 8)
|
|
1057
|
+
# Walk back until: messages[boundary] is not "tool" AND
|
|
1058
|
+
# messages[boundary-1] is not an assistant with tool_calls.
|
|
1059
|
+
while boundary > sys_len:
|
|
1060
|
+
if messages[boundary].get("role") == "tool":
|
|
1061
|
+
boundary -= 1
|
|
1062
|
+
continue
|
|
1063
|
+
prev = messages[boundary - 1]
|
|
1064
|
+
if prev.get("role") == "assistant" and prev.get("tool_calls"):
|
|
1065
|
+
boundary -= 1
|
|
1066
|
+
continue
|
|
1067
|
+
break
|
|
1068
|
+
|
|
1069
|
+
if boundary <= sys_len:
|
|
1070
|
+
# Whole tail is one tool-using sequence — nothing safe to compact
|
|
1071
|
+
return messages
|
|
1072
|
+
|
|
1073
|
+
recent = messages[boundary:]
|
|
1074
|
+
old = messages[sys_len:boundary]
|
|
1075
|
+
if not old:
|
|
1076
|
+
return messages
|
|
1077
|
+
|
|
1078
|
+
# Build an anchored "Work State" summary from old messages. This is the
|
|
1079
|
+
# structure opencode + codex converge on (Objective / Completed / Blocked /
|
|
1080
|
+
# Next Move) — a state machine a small local model can follow, not a flat
|
|
1081
|
+
# "Called X, Called Y" log. Built DETERMINISTICALLY (no LLM call — matches
|
|
1082
|
+
# codex's no-inference compaction, which matters on slow 16GB hardware):
|
|
1083
|
+
# completed = files written + commands that succeeded; blocked = commands
|
|
1084
|
+
# that failed / errors; objective = the first user request.
|
|
1085
|
+
objective = ""
|
|
1086
|
+
files_modified: list[str] = []
|
|
1087
|
+
ok_cmds: list[str] = []
|
|
1088
|
+
failed: list[str] = []
|
|
1089
|
+
|
|
1090
|
+
for m in old:
|
|
1091
|
+
role = m.get("role", "")
|
|
1092
|
+
content = str(m.get("content", ""))
|
|
1093
|
+
if role == "user" and not objective and not content.startswith(("Previous", "SYSTEM:", "##")):
|
|
1094
|
+
objective = content[:200]
|
|
1095
|
+
if role == "assistant" and m.get("tool_calls"):
|
|
1096
|
+
for tc in m["tool_calls"]:
|
|
1097
|
+
fn = tc.get("function", {})
|
|
1098
|
+
name = fn.get("name", "")
|
|
1099
|
+
try:
|
|
1100
|
+
_raw = fn.get("arguments", "{}")
|
|
1101
|
+
args = _raw if isinstance(_raw, dict) else json.loads(_raw)
|
|
1102
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
1103
|
+
args = {}
|
|
1104
|
+
if name in ("write_file", "append_file", "edit_file", "multi_edit"):
|
|
1105
|
+
p = args.get("path") or args.get("file_path")
|
|
1106
|
+
if p and p not in files_modified:
|
|
1107
|
+
files_modified.append(p)
|
|
1108
|
+
elif name == "bash":
|
|
1109
|
+
ok_cmds.append(str(args.get("command", ""))[:70])
|
|
1110
|
+
elif role == "tool":
|
|
1111
|
+
c = content.strip()
|
|
1112
|
+
if c.startswith(("Error", "[exit code", "REJECTED")) or "Traceback " in c:
|
|
1113
|
+
failed.append(c.splitlines()[0][:100] if c else "")
|
|
1114
|
+
|
|
1115
|
+
completed = []
|
|
1116
|
+
if files_modified:
|
|
1117
|
+
completed.append("Files created/edited: " + ", ".join(files_modified))
|
|
1118
|
+
if ok_cmds:
|
|
1119
|
+
completed.append("Commands run: " + " | ".join(ok_cmds[-5:]))
|
|
1120
|
+
blocked = [f for f in failed if f][-3:]
|
|
1121
|
+
|
|
1122
|
+
lines = ["## Work state so far (older turns compacted — build on this, don't redo it):"]
|
|
1123
|
+
if objective:
|
|
1124
|
+
lines.append(f"Objective: {objective}")
|
|
1125
|
+
lines.append("Completed:")
|
|
1126
|
+
lines.extend(f" - {c}" for c in completed) if completed else lines.append(" - (none yet)")
|
|
1127
|
+
if blocked:
|
|
1128
|
+
lines.append("Blocked / errors seen:")
|
|
1129
|
+
lines.extend(f" - {b}" for b in blocked)
|
|
1130
|
+
lines.append("Next move: continue the objective from the current file state; "
|
|
1131
|
+
"do not re-read files or re-run commands listed above.")
|
|
1132
|
+
|
|
1133
|
+
out.print_info("Context compacted — older turns summarized to a work-state note.")
|
|
1134
|
+
|
|
1135
|
+
return [
|
|
1136
|
+
*system,
|
|
1137
|
+
{"role": "user", "content": "\n".join(lines)},
|
|
1138
|
+
{"role": "assistant", "content": "Understood — continuing from the current state."},
|
|
1139
|
+
*recent,
|
|
1140
|
+
]
|
|
1141
|
+
|
|
1142
|
+
def _summarize_args(args: dict) -> str:
|
|
1143
|
+
"""Short summary of tool args for display."""
|
|
1144
|
+
if "path" in args:
|
|
1145
|
+
return args["path"]
|
|
1146
|
+
if "pattern" in args:
|
|
1147
|
+
return args["pattern"]
|
|
1148
|
+
if "command" in args:
|
|
1149
|
+
return args["command"][:60]
|
|
1150
|
+
if "query" in args:
|
|
1151
|
+
return args["query"][:40]
|
|
1152
|
+
return str(args)[:60]
|