Hi, this PR implements changes to reduce the input tokens/context fed into the model every turn. This reduces costs and lets low-context models go on for longer. This improves upon existing compacting features as well.
Summary
- compact oversized persisted subagent announcements before model replay while keeping task/header/instruction context
- adapt tool-result replay limits to tight context budgets without changing persisted history
- compact older duplicate compactable tool results and stale errored tool outputs in the model-facing message copy
- add focused ContextGovernor regression coverage for duplicate pruning, stale errors, subagent trimming, and adaptive tool-result limits
Verification
- /home/hamb/nanobot/.venv/bin/python -m py_compile nanobot/agent/context_governance.py tests/agent/test_runner_governance.py
- rtk git diff --check
Activity
7Review notes
I found two follow-up issues worth fixing before merge.
1. Large duplicate tool outputs are not actually deduplicated after budget normalization
prepare_for_model() runs apply_tool_result_budget() before compact_duplicate_tool_results(). That means oversized raw outputs may first be passed through maybe_persist_tool_result() and replaced with rendered reference strings. Those reference strings include the per-call persisted path, e.g. <tool_call_id>.txt, so two identical raw outputs from old and new calls become different strings before _content_digest() is computed.
Result: exact large duplicate outputs with the same tool name, normalized arguments, and user turn will not group together, because the digest is over the post-budget reference payload rather than the original result content. The new direct duplicate tests use raw in-memory content, so they do not cover the prepare_for_model() / persistence path.
Suggested fix: run duplicate/stale-error compaction before apply_tool_result_budget(), or compute duplicate identity before replacing oversized output with a persisted reference. Add an integration regression where two same-turn same-args tool results have identical content larger than the adaptive max_chars, with a real workspace, and assert the older one becomes [tool result omitted from context].
2. Subagent announcement parsing can split on marker text inside the result
_compact_subagent_announcement() finds the instruction marker with after_result.lower().find("summarize this naturally"). Because this is the first occurrence anywhere in the result body, a subagent result that quotes a prompt, issue comment, test fixture, or prior instruction containing that phrase will be split at the wrong place. In the worst case, the remainder is treated as instruction and reattached unbounded, so the compaction can preserve a large chunk it was supposed to trim.
Suggested fix: search for the trailing instruction boundary more narrowly, e.g. rfind("\n\nSummarize this naturally") case-insensitively, or only split when the marker starts a final paragraph. Add a regression where the long Result: body itself contains Summarize this naturally before the real trailing instruction, and assert the final compacted message is bounded and keeps the real instruction.
Review notes
I do not see a blocking correctness issue in the current revision. The earlier review concerns appear addressed: duplicate compaction now includes a content digest, it runs before tool-result budget persistence, stale error compaction keeps short errors, and subagent instruction parsing now uses the final instruction marker.
One test coverage nit worth fixing before merge:
test_compact_duplicate_tool_results_skips_different_arguments() passes for the wrong reason
This test uses web_search, but duplicate pruning only considers tools in COMPACTABLE_TOOLS. Since web_search is not compactable, the test would still pass even if argument-sensitive duplicate grouping were broken.
Suggested fix: make the test use a compactable tool such as exec, grep, or read_file with identical content but different normalized arguments, e.g. two exec calls with {"cmd":"pytest -q"} and {"cmd":"ruff check ."}. Then assert both payloads remain visible.
Non-blocking design note: _tool_call_signatures() counts every role=user message as a new user turn. That is conservative for correctness, but if internal [Subagent ...] announcements can occur between repeated same-turn tool calls, it may prevent otherwise-safe duplicate pruning. Not a merge blocker unless that path is common enough to matter for context pressure.
This pull request is a draft
Merging is unavailable until it is marked ready for review.
Review notes
I found two issues worth fixing before merge.
1. Same-turn duplicate compaction can still drop distinct state snapshots
compact_duplicate_tool_results()now includesuser_turnin the signature, which fixes the earlier cross-turn concern, but it still compacts repeated calls with the same tool name/arguments within a single user turn without checking the actual content.That is still unsafe for agent runs. A single user turn can contain intervening state changes from tool calls, for example:
exec({"cmd": "pytest -q"})failsexec({"cmd": "pytest -q"})fails differently or passesBoth
execcalls can have the same normalized arguments and the sameuser_turn, but the older result is not a duplicate; it is a prior workspace snapshot. The same applies togrep/read_fileafter an edit in the same assistant loop.Suggested fix: only compact if the actual content is identical, or include a content hash in the grouping key. If the goal is to compact repeated snapshots with identical payloads, content equality is the direct condition. Add a regression test where two same-turn same-args tool results have different content and both are preserved.
2. Stale error compaction erases short, high-signal errors for no token gain
compact_stale_error_tool_results()calls_is_compactable_tool_result(..., min_chars=0)and then replaces any stale compactableError:payload with a generic[tool result omitted from context]summary.For short errors like
Error: Permission deniedorError: file not found, this saves essentially no context and removes the exact failure detail the model may need if the user later asks about the earlier error. It can also become more aggressive than intended if internalrole=usersubagent announcements are counted as “newer user turns.”Suggested fix: either keep short errors intact and only compact large error payloads, or preserve the first error line/type in the summary, e.g.
[exec error omitted from context: Permission denied]. Also consider not counting[Subagent ...]role-user announcements as human user turns when deciding staleness.