diff --git a/CHANGELOG.md b/CHANGELOG.md index 2406e81..37642b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ All notable changes to lcode are documented here. The format follows ## [Unreleased] +### Changed + +- Long sessions are summarized earlier: once the conversation reaches 64K tokens (`compact_at`), + even when the window is larger. Before, a 256K window was summarized only at about 220K, far past + where small models work well. lcode still removes old tool output first, and summarizes only if + that's not enough. `lcode config set compact_at 0` keeps only the 85% rule, and + `lcode bench --compact-at` compares ceilings. Measured with qwen3.6-35b: making room earlier + doesn't cost tasks (21 of 24 either way) and saved 17% of the time. See + [Context management](https://nasser1941.github.io/lcode/how-it-works/#context-management). +- Summaries keep your latest requests (up to eight) word for word, so the goal survives the + retelling. `/context` shows where the session makes room. + +### Fixed + +- After lcode removed an earlier file read to make room, the model sometimes answered questions + about that file from memory, and got them wrong, instead of reading it again. The files whose + only copy went are now named next to the request it's working on, and the note in their place + says plainly that the content is gone. A conversation that was just summarized isn't summarized + again until it has grown by a quarter of `compact_at`, which must be at least 16K (or 0). + ## [0.26.0] - 2026-10-10 ### Added diff --git a/docs/bench.md b/docs/bench.md index fac0173..d412a96 100644 --- a/docs/bench.md +++ b/docs/bench.md @@ -51,7 +51,7 @@ Each task normally starts a new conversation. `--session` runs them all in **one instead, as a long working session would, so the context fills up and lcode's [context management](how-it-works.md#context-management) has to work: the summary shows how often it removed old tool output and how often it summarized the conversation. `--no-prune` turns the -removal off, to compare. +removal off, to compare, and `--compact-at` sets the ceiling (`--compact-at 0`: only at 85%). ```bash lcode bench --context 32k --session --rounds 3 @@ -74,6 +74,17 @@ at no measurable cost in tasks solved or time. The bench tasks are independent o which is where summaries hurt least; in a real session, where later requests build on earlier ones, keeping the conversation intact matters more. +With a large window, `compact_at` decides when lcode makes room. In a 128K window, 24 tasks in one +conversation reach about 40K tokens, so the default ceiling of 64K never comes into play. Making +room earlier than needed doesn't cost tasks: + +| 128K window, 24 tasks | No room made (peak ~40K) | `--compact-at 32k` | +|---|---|---| +| Conversation summarized | 0 times | 1 time (old tool output removed once) | +| Tasks solved | 21 of 24 | 21 of 24 | +| Time | 33 min | 27.5 min | +| Average prompt | 22.9K tokens | 16.6K tokens | + ## The tasks Every task runs in a new temporary folder, with every permission granted (`yolo` mode) and web diff --git a/docs/configuration.md b/docs/configuration.md index ee56c30..3fe2118 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -37,6 +37,7 @@ lcode config path # print the file location | `repo_map` | `true` | Give the model a ranked [map of the repository](search.md) | | `embed_model` | `auto` | Embedding model for [semantic code search](search.md#semantic-code-search): `auto`, `off` or a model name | | `lsp` | `auto` | Use installed [language servers](lsp.md) for code navigation and errors after edits: `auto` or `off` | +| `compact_at` | `65536` | Make room (prune, then summarize) once the conversation reaches this many tokens, even in a larger window (at least 16K); `0` leaves only the 85% rule ([why](how-it-works.md#context-management)) | | `prune` | `true` | When the context is 85% full, first remove old tool output, and summarize the conversation only if that's not enough ([how](how-it-works.md#context-management)) | | `subagents` | `true` | Let the model hand tasks to [subagents](agents.md) with their own context | | `max_parallel_agents` | `1` | Subagents that run at the same time; more needs `OLLAMA_NUM_PARALLEL` on the Ollama server ([details](agents.md#several-at-once)) | diff --git a/docs/how-it-works.md b/docs/how-it-works.md index f51297c..a8ea02b 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -73,21 +73,32 @@ in a temporary git worktree, and their changes come back as a diff. ## Context management Ollama keeps the processed conversation in its KV cache, so each turn only processes the new tokens. -lcode tracks how full the window is from Ollama's token counts. Most of a long session's context is -old tool output, so when the window is 85% full: +lcode tracks how full the window is from Ollama's token counts. It makes room when the window is +85% full, or when the conversation reaches 64K tokens (`compact_at`), whichever comes first. +Small models lose track in long contexts well before a large window is full: in one long session, +Qwen3.6 35B started repeating itself and overlooking what it had read once the conversation passed +about 75K tokens. Most of a long session's +context is old tool output, so: 1. **lcode removes old tool output first.** File contents, command output and search results from before your last two requests become one-line notes that say what was there, such as - `[lcode: read src/app.py earlier; removed to save context. Read it again if you need it.]`. + `[lcode: read src/app.py earlier; its content was removed …. Read it again before answering anything about it.]`. An earlier copy of a file that was read again later goes too, even in recent requests. Subagent - reports, skill instructions and the approved plan stay. -2. **If that brings the conversation under 70%,** the work simply continues: nothing is summarized, + reports, skill instructions and the approved plan stay. The files whose only copy went are also + named next to the request the model is working on, since models tend to overlook a note deep in + the conversation and answer from memory instead of reading the file again. +2. **If that brings the conversation well under the limit** (70% of the window, or about 53K + tokens with `compact_at`), the work simply continues: nothing is summarized, and the conversation itself stays word for word. If it's not, lcode also removes the tool output of your previous request, keeping only the latest one's. (`lcode config set prune false` skips this step.) 3. **Otherwise lcode summarizes.** It asks whether anything is worth [remembering](memory.md), since the summary leaves details out, then has the model write a summary of the conversation (goals, - findings, files changed, next steps) and continues from it. + findings, files changed, next steps) and continues from it. Your latest requests (up to eight) + are kept word for word under the summary, so the goal isn't lost in the retelling. + +`lcode config set compact_at 128k` moves the ceiling, and `0` turns it off, leaving only the 85% +rule. `/context` shows where the current session makes room. Long tool output (over 30,000 characters) keeps its beginning, its end and the lines in between that look like errors. The full output is saved under `~/.local/state/lcode/outputs/` (for a week) diff --git a/src/lcode/agent.py b/src/lcode/agent.py index 693bf90..0ccb888 100644 --- a/src/lcode/agent.py +++ b/src/lcode/agent.py @@ -234,6 +234,7 @@ class Settings: notify_after: int = 30 # seconds: a request this long notifies when it's done or waits for an answer max_parallel_agents: int = 1 secret_check: bool = True # scan what git commit and git push would add (lcode.secretscan) + compact_at: int = 0 # tokens: make room at this size even when the window is larger (0: only at 85%) repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats) @@ -270,6 +271,7 @@ def __init__(self, ollama: Ollama, settings: Settings, cwd: Path, console: Conso self.agent_runs: list[subagents.Record] = [] # subagents run in this session, for /agents self.plan = "" # the plan the user approved; kept through compaction self.pruned = self.compacted = 0 # how often the context was pruned or summarized automatically + self.summarized_size = 0 # tokens right after the last summary (0: none in this conversation) self._extensions: tuple[Path, extensions.Extensions] | None = None self.skills_loaded: set[str] = set() # skills whose instructions are in the conversation # Set on subagents (see lcode.subagents): @@ -514,6 +516,7 @@ def web_prompt(self) -> str: return WEB_PROMPT.format(tools=tools, date=dt.date.today().isoformat()) def reset(self) -> None: + self.summarized_size = 0 self.messages = [{"role": "system", "content": self.system_prompt()}] self._mcp_prompt = "" self.tools.read_mtimes.clear() @@ -1046,13 +1049,27 @@ def missing_image(shown: str) -> None: return text + ("\n\n" + "\n\n".join(attached) if attached else "") # -- context management + def compact_limit(self) -> int: + """The tokens in use at which lcode makes room: 85% of the window, or `compact_at` when that's lower. + Small models lose track in long contexts well before a large window is full.""" + limit = int(AUTO_COMPACT_RATIO * self.settings.context) + if self.settings.compact_at > 0: + limit = min(limit, self.settings.compact_at) + return limit + def maybe_compact(self) -> None: - if self.ctx_used <= AUTO_COMPACT_RATIO * self.settings.context: + limit = self.compact_limit() + if self.ctx_used <= limit: return + enough = int(limit * PRUNED_ENOUGH / AUTO_COMPACT_RATIO) # after pruning, a summary isn't needed below this if self.settings.prune: - saved = context_tools.prune(self.messages) # tool output from before the last two requests - if self.ctx_used - saved // 3 > PRUNED_ENOUGH * self.settings.context: - saved += context_tools.prune(self.messages, keep=1) # not enough: before the last request + removed: list[str] = [] # files and pages whose only copy goes + saved = context_tools.prune(self.messages, removed=removed) # tool output from before the last two requests + if self.ctx_used - saved // 3 > enough: + saved += context_tools.prune( + self.messages, keep=1, removed=removed + ) # not enough: before the last request + context_tools.note_removed(self.messages, removed) if saved: self.pruned += 1 self.ctx_used = max(len(self.messages[0]["content"]) // 3, self.ctx_used - saved // 3) @@ -1060,9 +1077,17 @@ def maybe_compact(self) -> None: f"[dim]Removed old tool output from the conversation (~{format_tokens(saved // 3)} tokens) to " "make room.[/]" ) - if self.ctx_used <= PRUNED_ENOUGH * self.settings.context: + if self.ctx_used <= enough: return - self.console.print("[yellow]Context is nearly full — compacting the conversation…[/]") + window_full = self.ctx_used > AUTO_COMPACT_RATIO * self.settings.context + if not window_full and self.summarized_size and self.ctx_used - self.summarized_size < limit // 4: + return # summarized not long ago: summarizing again so soon would only lose more + if limit < AUTO_COMPACT_RATIO * self.settings.context: + self.console.print( + f"[yellow]The conversation reached {format_tokens(limit)} tokens (compact_at) — summarizing it…[/]" + ) + else: + self.console.print("[yellow]Context is nearly full — compacting the conversation…[/]") self.compacted += 1 self.compact() @@ -1083,9 +1108,13 @@ def compact(self, focus: str = "") -> None: with self.console.status("[cyan]Compacting…[/]"): for chunk in self.chat([*self.messages, {"role": "user", "content": instructions}], None, False): summary += chunk.get("message", {}).get("content", "") + requests = context_tools.user_requests(self.messages) self.messages = [ {"role": "system", "content": self.system_prompt()}, - {"role": "user", "content": f"[Summary of our conversation so far]\n\n{summary}"}, + { + "role": "user", + "content": f"{sessions.SUMMARY_PREFIX}\n\n{summary}" + context_tools.requests_block(requests), + }, {"role": "assistant", "content": "Got it — I have the context from the summary and will continue."}, ] if self.plan: # the approved plan stays word for word @@ -1097,6 +1126,7 @@ def compact(self, focus: str = "") -> None: ) self.skills_loaded.clear() self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3 + self.summarized_size = self.ctx_used self.reflected = len(self.messages) self.console.print(Panel(Markdown(summary), title="Compacted summary", border_style="blue")) diff --git a/src/lcode/api.py b/src/lcode/api.py index c48a8e3..634a1df 100644 --- a/src/lcode/api.py +++ b/src/lcode/api.py @@ -196,6 +196,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non notify_after=cfg["notify_after"], secret_check=cfg["secret_check"], repeat_limit=cfg["repeat_limit"], + compact_at=cfg["compact_at"], ) agent = Agent(ollama, settings, cwd, console=console) agent.interactive = options.interactive diff --git a/src/lcode/cli.py b/src/lcode/cli.py index 25ead50..03d569d 100644 --- a/src/lcode/cli.py +++ b/src/lcode/cli.py @@ -579,6 +579,7 @@ def cmd_bench(args) -> None: web="off", checkpoints=False, prune=not args.no_prune, + compact_at=cfg["compact_at"] if args.compact_at is None else args.compact_at, repo_map=args.repo_map, embed_model=cfg["embed_model"] if args.code_search else "off", ) @@ -678,6 +679,14 @@ def cmd_chat(args) -> None: sys.exit(exit_code) +def compact_tokens(value: str) -> int: + """`--compact-at`: a token count such as 64k, or 0.""" + try: + return config.parse_compact_at(value) + except ConfigError as e: + raise argparse.ArgumentTypeError(str(e)) from e + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="lcode", @@ -765,6 +774,12 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]: p.add_argument("--repo-map", action="store_true", help="give the model the repository map (off by default)") p.add_argument("--code-search", action="store_true", help="index each task's folder and offer semantic code search") p.add_argument("--no-prune", action="store_true", help="don't remove old tool output (to compare, with --session)") + p.add_argument( + "--compact-at", + type=compact_tokens, + metavar="TOKENS", + help="summarize at this many tokens, e.g. 64k (default: the compact_at setting; 0: only at 85%%)", + ) subs["bench"] = p p = argparse.ArgumentParser( prog="lcode index", diff --git a/src/lcode/config.py b/src/lcode/config.py index 01830db..2d07b53 100644 --- a/src/lcode/config.py +++ b/src/lcode/config.py @@ -61,6 +61,7 @@ "notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"), "repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"), "secret_check": (True, bool, "before git commit and git push, look for passwords, keys and tokens they'd add"), + "compact_at": (65536, int, "summarize at this many tokens even when the window is larger (0: only at 85%)"), "max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"), } ENV_OVERRIDES = { @@ -81,6 +82,19 @@ class ConfigError(ValueError): pass +MIN_COMPACT_AT = 16384 # below this, the system prompt and a summary alone come close to the ceiling + + +def parse_compact_at(value: str | int) -> int: + """compact_at: a token count like parse_context's, at least 16K, or 0 (off).""" + if str(value).strip().lower() in ("0", "off", "false", "none"): + return 0 + tokens = parse_context(value) + if tokens < MIN_COMPACT_AT: + raise ConfigError(f"compact_at {tokens} is too small: use at least 16k, or 0 to turn it off") + return tokens + + def parse_context(value: str | int) -> int: """Parse '131072', '128k', '128K' or '1m' into a token count (k = 1024).""" if isinstance(value, int): @@ -118,6 +132,8 @@ def coerce(key: str, value: object) -> object: _, typ, _ = SETTINGS[key] if key == "context": return parse_context(value) # type: ignore[arg-type] + if key == "compact_at": + return parse_compact_at(value) # type: ignore[arg-type] if key in ("ollama_host", "searxng_url"): return normalize_host(str(value)) if key == "base_url": diff --git a/src/lcode/context.py b/src/lcode/context.py index c8efe31..f009553 100644 --- a/src/lcode/context.py +++ b/src/lcode/context.py @@ -29,6 +29,10 @@ KEEP_RECENT_REQUESTS = 2 # tool results of the last requests stay as they are MIN_PRUNE_CHARS = 400 # shorter results aren't worth replacing PRUNED = "[lcode: " # how stubs start, so they're never pruned twice +GONE = ( # a removed file read: firm, because models answer from the stub instead of reading it again + "earlier; its content was removed to save context and isn't in this conversation any more. Read it again " + "before answering anything about it.]" +) KEEP_TOOLS = {"agent", "skill", "memory", "todo_write", "present_plan"} # short, or still guiding the work ERROR_LINE = re.compile( r"error|exception|traceback|failed|failure|fatal|panic|assert|denied|not found|cannot|can't|undefined", re.I @@ -37,6 +41,56 @@ OUTPUT_DAYS = 7 # saved full outputs are deleted after this +# ----------------------------------------------------------------------------- the user's requests + +REQUESTS_HEADER = "[The user's requests in this conversation, word for word, oldest first]" +MAX_REQUESTS = 8 # the most recent ones are kept through a summary +MAX_REQUEST_CHARS = 1500 +# What lcode appends to the user's words: attached files, images and folders, and its own notes. +ATTACHED = re.compile(r"\n\n(?: str: + """The user's own words in a message, without what lcode attached to it.""" + cut = ATTACHED.search(content) + return (content[: cut.start()] if cut else content).strip() + + +def user_requests(messages: list[dict]) -> list[str]: + """What the user asked in the conversation, oldest first, including the requests an earlier summary kept.""" + requests: list[str] = [] + for message in messages: + content = message.get("content") or "" + if message.get("role") != "user": + continue + if content.startswith(SUMMARY_PREFIX): + start = content.find(REQUESTS_HEADER) + if start >= 0: + rest = content[start + len(REQUESTS_HEADER) :] + try: + kept, _ = json.JSONDecoder().raw_decode(rest[rest.index("[") :]) + requests += [str(r) for r in kept] + except ValueError: + pass + continue + if content.startswith("[lcode]"): + continue # lcode's own nudges + text = request_text(content) + if text: + requests.append(text) + return requests + + +def requests_block(requests: list[str]) -> str: + """The user's latest requests, word for word, to keep the goal through a summary.""" + if not requests: + return "" + kept = [r if len(r) <= MAX_REQUEST_CHARS else f"{r[: MAX_REQUEST_CHARS - 200]} […] {r[-150:]}" for r in requests] + older = len(kept) - MAX_REQUESTS + note = f" (and {older} earlier, in the summary)" if older > 0 else "" + return f"\n\n{REQUESTS_HEADER}{note}\n{json.dumps(kept[-MAX_REQUESTS:], ensure_ascii=False, indent=1)}" + + # ----------------------------------------------------------------------------- pruning @@ -63,7 +117,7 @@ def stub(name: str, args: dict, content: str) -> str: if args.get("offset") or args.get("limit"): start = int(args.get("offset") or 1) span = f" from line {start}" - return f"{PRUNED}read {args.get('path', 'a file')}{span} earlier; removed to save context. Read it again if you need it.]" + return f"{PRUNED}read {args.get('path', 'a file')}{span} {GONE}" if name == "bash": command = " ".join(str(args.get("command", "")).split()) command = command if len(command) <= 80 else command[:79] + "…" @@ -92,8 +146,9 @@ def boundary(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int: return requests[-keep] if len(requests) >= keep else 0 -def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int: - """Replace stale tool results with stubs, in place. Returns the number of characters saved.""" +def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS, removed: list[str] | None = None) -> int: + """Replace stale tool results with stubs, in place. Returns the number of characters saved; the files and + pages whose only copy went are added to `removed`.""" calls = calls_for_results(messages) edge = boundary(messages, keep) # A read is superseded by a later read of the whole file, or of the same lines: not by a later @@ -118,12 +173,34 @@ def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int: continue new = stub(name, args, content) if reread: - new = new.replace("earlier; removed", "earlier (read again later); removed") + new = new.replace( + GONE, "earlier (read again later); removed to save context. Read it again if you need it.]" + ) + elif removed is not None and name in ("read_file", "web_fetch"): + what = str(args.get("path") or args.get("url") or "") + if what and what not in removed: + removed.append(what) messages[i]["content"] = new saved += len(content) - len(new) return saved +def note_removed(messages: list[dict], removed: list[str]) -> None: + """Tell the model, next to the request it's working on, which files it no longer has: a stub deep in the + conversation is easy to overlook, and models then answer from memory instead of reading again.""" + if not removed: + return + for message in reversed(messages): + content = str(message.get("content", "")) + if message.get("role") == "user" and not content.startswith(("[lcode]", SUMMARY_PREFIX)): + names = ", ".join(removed[:10]) + (f" and {len(removed) - 10} more" if len(removed) > 10 else "") + message["content"] = ( + f"{content}\n\n[lcode] To make room, lcode removed what you read earlier from: {names}. It isn't in " + "this conversation any more: read it again before answering anything about it, don't answer from memory." + ) + return + + # ----------------------------------------------------------------------------- long output diff --git a/src/lcode/repl.py b/src/lcode/repl.py index 2bdc55c..d746e23 100644 --- a/src/lcode/repl.py +++ b/src/lcode/repl.py @@ -242,7 +242,8 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None: c.print( f"In use: {format_tokens(agent.ctx_used)} of {format_tokens(s.context)} tokens " f"({100 * agent.ctx_used / s.context:.0f}%) in {len(agent.messages)} message" - f"{'' if len(agent.messages) == 1 else 's'}; auto-compacts at 85%." + f"{'' if len(agent.messages) == 1 else 's'}; makes room at {format_tokens(agent.compact_limit())} " + + ("(compact_at)." if agent.compact_limit() < AUTO_COMPACT_RATIO * s.context else "(85%).") ) if agent.mcp and agent.mcp.ready(): how = "found on demand" if agent.mcp.searching(s.context) else "sent with every request" diff --git a/tests/test_context.py b/tests/test_context.py index 2c40065..71ae081 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,3 +1,5 @@ +import pytest + from conftest import output, reply from lcode import context from lcode.hardware import Hardware @@ -159,3 +161,87 @@ def test_context_shows_what_uses_it(make_agent, repo, monkeypatch): monkeypatch.setattr("builtins.input", lambda prompt="": "") # keep the window size handle_command(agent, "/context", HW) assert "What uses the context" in output(agent) and "tool results: read_file" in output(agent) + + +def test_a_large_window_is_summarized_at_the_ceiling(make_agent): + assert make_agent(context=262144, compact_at=65536).compact_limit() == 65536 + assert make_agent(context=262144, compact_at=0).compact_limit() == int(0.85 * 262144) + assert make_agent(context=32768, compact_at=65536).compact_limit() == int(0.85 * 32768) + agent = make_agent([reply("Summary.")], context=262144, compact_at=8192) + agent.messages = conversation() + agent.ctx_used = 9000 # 3% of the window, but past the ceiling + agent.maybe_compact() + assert agent.pruned == 1 and agent.compacted == 1 + assert "The conversation reached 8K tokens (compact_at)" in output(agent) + agent.ctx_used = 5000 + agent.maybe_compact() # under the ceiling again: nothing to do + assert agent.compacted == 1 + + +def test_summaries_keep_the_users_requests_word_for_word(make_agent): + agent = make_agent([reply("Summary one."), reply("ok"), reply("Summary two.")], context=16384) + agent.messages = conversation() + agent.messages[1]["content"] = 'make it 2x3\n\n\n{"grid": "3x3"}\n' + agent.compact() + summary = agent.messages[1]["content"] + assert summary.startswith("[Summary of our conversation so far]\n\nSummary one.") + assert context.REQUESTS_HEADER in summary and '"make it 2x3"' in summary and "config.json" not in summary + agent.run_turn("now commit it") + agent.compact() + assert context.user_requests(agent.messages)[:1] == ["make it 2x3"] + assert context.user_requests(agent.messages)[-1] == "now commit it" + assert agent.messages[1]["content"].count(context.REQUESTS_HEADER) == 1 + + +def test_only_the_latest_requests_are_kept(make_agent): + requests = [f"request {n}" for n in range(12)] + ["x" * 5000] + block = context.requests_block(requests) + assert "(and 5 earlier, in the summary)" in block and '"request 4"' not in block and '"request 5"' in block + assert "[…]" in block and len(block) < 3000 + + +def test_context_shows_when_it_makes_room(make_agent): + agent = make_agent(context=262144, compact_at=65536) + handle_command(agent, "/context 262144", HW) + assert "makes room at 64K (compact_at)" in output(agent) + + +def test_a_fresh_summary_is_not_summarized_again_right_away(make_agent): + agent = make_agent([reply("Summary."), reply("Summary again.")], context=262144, compact_at=16384) + agent.messages = conversation() + agent.ctx_used = 17000 + agent.maybe_compact() + assert agent.compacted == 1 + agent.summarized_size = 15000 # the summary and the requests it keeps are large + agent.ctx_used = 17000 # past the ceiling, but it grew only 2K since + agent.maybe_compact() + assert agent.compacted == 1 + agent.ctx_used = 15000 + 16384 // 4 + 1 # it grew by a quarter of the ceiling: summarize again + agent.maybe_compact() + assert agent.compacted == 2 + agent.summarized_size, agent.ctx_used = 200000, 230000 # the window itself is full: always + agent.ollama.scripts.append(reply("Summary three.")) + agent.maybe_compact() + assert agent.compacted == 3 + + +def test_compact_at_has_a_floor(): + from lcode import config + from lcode.config import ConfigError + + assert config.coerce("compact_at", "64k") == 65536 and config.coerce("compact_at", "off") == 0 + with pytest.raises(ConfigError, match="at least 16k"): + config.coerce("compact_at", "8k") + + +def test_the_model_is_told_next_to_its_request_what_it_no_longer_has(make_agent): + agent = make_agent([reply("ok")], context=16384) + agent.messages = conversation(BIG * 10) + agent.messages[12]["tool_calls"] = [tool("read_file", path="c.py"), tool("read_file", path="b.py")] + agent.ctx_used = int(0.9 * 16384) + agent.maybe_compact() + assert agent.messages[11]["content"].startswith( + "third request\n\n[lcode] To make room, lcode removed what you read earlier from: a.py. It isn't in this " + "conversation any more: read it again" + ) + assert context.user_requests(agent.messages)[-1] == "third request"