Files
6krrt/tests/test_context_prune.py
adlee-was-taken 5f7716e121 fix: resolve context-pruning-and-framing review findings
Address the 11 confirmed bugs and 4 cleanups from
context-pruning-and-framing-review.md, plus the image-accounting
regression it found on a second pass.

classifier framing:
- _previous_context walks back to the nearest assistant turn only,
  skipping system/user/tool so raw tool output and the system prompt
  never contaminate the framing signal; reuses context_prune.extract_text
  instead of a duplicated block parser.
- classify() drops its redundant if/not-context branch.
- framing instruction moved out of the static system prompt and into
  _classifier_user_content so it travels only with content that justifies
  it (respects context_framing opt-out).

context pruning:
- extract_text counts image_url bytes so image-bearing tool results are
  sized (and pruned) correctly.
- -- new, follow-up review -- image_url blocks now count toward the size
  trigger but not toward the replacement text, and are stubbed to a short
  marker when the message is trimmed, so an image-heavy tool result
  genuinely shrinks rather than staying full-size while tokens_saved lied
  and base64 leaked into the text block.
- tokens_saved is now the honest whole-list before/after reduction
  (max(orig - final, 0)), never a per-message estimate that could drift.
- max_summarize_chars validated >= 3000 at config load; trim branch guards
  against negative math and message growth.
- recency guard protects the newest tool result even with no user turn.
- non-text blocks survive trimming (type preserved) rather than being
  flattened away.
- dropped/stat terminology corrected to summarized; _turn_of() removed;
  module-level pinch defaults removed in favor of PinchConfig.

Dispatch and config:
- prune_context runs once before the measured-context routing decision
  (reused at dispatch, no double-prune, no in-place mutation); no-op when
  pinch is disabled.
- dead context=prev_context arg removed from the measured reroute.
- TaskRequest.context dual use documented.

15 new regression tests pin the fixes; 545 tests pass.
2026-08-23 22:23:08 -04:00

386 lines
15 KiB
Python

"""Pure relevance-based context pruning.
Ported from the MIT-licensed llmrouter "pinch" module, reduced to the safe
invariants 6krrt requires: user/assistant/system messages are always kept
verbatim, only old tool results are trimmed, and message order / role pairing
survives so the result still parses as a conversation.
"""
from __future__ import annotations
from context_prune import estimate_tokens, extract_text, prune_context
from dispatcher import estimate_prompt_tokens
def _user(text: str) -> dict:
return {"role": "user", "content": text}
def _assistant(text: str) -> dict:
return {"role": "assistant", "content": text}
def _tool(name: str, content: str) -> dict:
return {"role": "tool", "name": name, "content": content}
def test_short_conversation_is_untouched():
messages = [_user("hi"), _assistant("hello")]
out, stats = prune_context(messages, budget_tokens=50000)
assert out == messages
assert stats["pruned"] is False
def test_user_and_assistant_messages_always_kept():
messages = [
_user("big user " * 5000),
_assistant("big assistant " * 5000),
_user("tail"),
]
out, stats = prune_context(messages, budget_tokens=100)
assert stats["pruned"] is True
assert len(out) == len(messages)
assert out[0]["role"] == "user"
assert out[1]["role"] == "assistant"
assert out[2]["role"] == "user"
# All three survived verbatim; only their absolute content is untouched.
assert out[0]["content"] == messages[0]["content"]
assert out[2]["content"] == "tail"
def test_old_tool_results_beyond_window_are_trimmed():
messages = [
_tool("read", "huge tool output " * 2000), # old, should be trimmed
_assistant("step one"),
_user("continue"),
_assistant("ok"),
]
out, stats = prune_context(messages, budget_tokens=100, keep_last_turns=1)
assert stats["pruned"] is True
assert stats["tokens_saved"] > 0
# The old tool result got shortened (head + tail + elision marker).
tool_out = out[0]
assert tool_out["role"] == "tool"
assert "chars trimmed" in tool_out["content"]
assert out[1]["content"] == "step one"
assert out[2]["content"] == "continue"
assert out[3]["content"] == "ok"
def test_recent_turn_is_protected_from_pruning():
# keep_last_turns=1 protects the last user turn AND its tool results.
messages = [
_user("first"),
_tool("search", "old result " * 3000),
_assistant("first answer"),
_user("second"),
_tool("search", "recent result " * 3000),
_assistant("second answer"),
]
out, stats = prune_context(messages, budget_tokens=100, keep_last_turns=1)
assert stats["pruned"] is True
# The recent tool result (after the 2nd user) survives verbatim.
recent_tool = [m for m in out if m.get("role") == "tool"][-1]
assert recent_tool["content"] == messages[4]["content"]
# Old tool result got trimmed.
old_tool = [m for m in out if m.get("role") == "tool"][0]
assert "chars trimmed" in old_tool["content"]
def test_tool_result_role_pairing_preserved():
# The tool message keeps its name and position, so the API still parses.
messages = [
_user("q"),
_tool("search", "x" * 6000),
_assistant("a"),
]
out, stats = prune_context(messages, budget_tokens=50, keep_last_turns=0)
tool_out = [m for m in out if m.get("role") == "tool"][0]
assert tool_out["name"] == "search"
assert out[0]["role"] == "user"
assert out[1]["role"] == "tool"
assert out[2]["role"] == "assistant"
def test_short_tool_result_is_dropped_to_placeholder():
# A large user message pushes the conversation over budget, so the SHORT
# old tool result (under max_summarize_chars but longer than the marker) is
# dropped to a placeholder that is shorter than the result it replaces.
result_text = "result " * 40 # 280 chars > placeholder, < 4000 cap
messages = [
_user("big user " * 5000),
_tool("search", result_text),
_assistant("a"),
]
out, stats = prune_context(messages, budget_tokens=100, keep_last_turns=0)
assert stats["pruned"] is True
tool_out = [m for m in out if m.get("role") == "tool"][0]
assert "[search: result omitted]" in tool_out["content"]
assert len(tool_out["content"]) < len(result_text)
def test_tiny_tool_result_is_kept_when_placeholder_loses_tokens():
# A result shorter than the placeholder marker is kept verbatim: replacing
# it would ADD tokens, which is not the point of pruning.
messages = [
_user("big user " * 5000),
_tool("search", "hi"),
_assistant("a"),
]
out, stats = prune_context(messages, budget_tokens=100, keep_last_turns=0)
tool_out = [m for m in out if m.get("role") == "tool"][0]
assert tool_out["content"] == "hi"
def test_stats_report_tokens_saved():
messages = [_user("q"), _tool("search", "y" * 9000), _assistant("a")]
out, stats = prune_context(messages, budget_tokens=10, keep_last_turns=0)
assert stats["pruned"] is True
assert stats["original_tokens"] > stats["final_tokens"]
assert stats["tokens_saved"] > 0
assert stats["summarized"] >= 1
def test_extract_text_handles_content_blocks():
msg = {
"role": "tool",
"content": [
{"type": "text", "text": "hello"},
{"type": "tool_result", "content": "world"},
],
}
assert "hello" in extract_text(msg)
assert "world" in extract_text(msg)
def test_estimate_tokens_is_crude_but_consistent():
assert estimate_tokens("x" * 30) == 10
assert estimate_tokens("") == 0
assert estimate_tokens(None) == 0
def test_max_summarize_chars_below_3000_never_grows_or_goes_negative():
# Regression: a max_summarize_chars well below 3000 (here 100) with a
# moderately long tool result used to head/tail-trim into something LONGER
# than the input and a negative tokens_saved. It must never grow or negate.
content = "tool output " * 100 # 1200 chars
messages = [
_user("big user " * 5000),
_tool("read", content),
_assistant("a"),
]
out, stats = prune_context(
messages,
budget_tokens=100,
keep_last_turns=0,
max_summarize_chars=100,
)
tool_out = [m for m in out if m.get("role") == "tool"][0]
assert len(tool_out["content"]) < len(content) # strictly shorter
assert stats["tokens_saved"] >= 0
assert "[-" not in tool_out["content"] # no negative trim marker
def test_extract_text_counts_image_url_blocks():
# Regression: image_url blocks used to read as "", undercounting the
# message and letting pruning miss it. They must contribute their url.
msg = {
"content": [
{"type": "text", "text": "describe this"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
],
}
text = extract_text(msg)
assert text != ""
assert "describe this" in text
assert "AAAA" in text
def test_extract_text_flattens_nested_tool_result_content_list():
# Regression: a tool_result whose content is itself a list of blocks was
# str()'d as a repr instead of being flattened like a top-level list.
msg = {
"content": [
{
"type": "tool_result",
"content": [
{"type": "text", "text": "nested text"},
{"type": "image_url", "image_url": {"url": "http://x/img.png"}},
],
},
],
}
text = extract_text(msg)
assert "nested text" in text
assert "img.png" in text
def test_stats_use_summarized_and_length_is_always_preserved():
# Regression: the stat was named "dropped" but nothing is ever removed —
# content is replaced in place, so len(pruned) == len(messages) always.
messages = [
_user("q"),
_tool("search", "x" * 200),
_assistant("a"),
]
out, stats = prune_context(
messages,
budget_tokens=50,
keep_last_turns=0,
max_summarize_chars=5000,
)
assert "dropped" not in stats
assert "summarized" in stats
assert stats["summarized"] >= 1
assert len(out) == len(messages)
def test_zero_user_messages_protect_newest_tool_result():
# Regression: with no user message the recency guard collapsed to
# protected_from = len(messages), so even the newest tool result (the one
# the next turn needs) was trimmed. It must be protected.
messages = [
{"role": "system", "content": "sys"},
{"role": "assistant", "content": "call tool", "tool_calls": [{"id": "t1"}]},
{"role": "tool", "name": "read", "content": "x" * 20000},
]
out, stats = prune_context(messages, budget_tokens=100, keep_last_turns=4)
assert stats["pruned"] is True
tool_out = [m for m in out if m.get("role") == "tool"][0]
assert tool_out["content"] == messages[2]["content"]
def test_structured_tool_result_keeps_image_block_when_trimmed():
# Regression: trimming flattened structured content to a string, dropping
# the image_url part permanently. The non-text block must survive — kept
# as an image_url block (so the model knows an image was present), with
# its payload shrunk to a stub rather than shipped full-size.
messages = [
_user("big user " * 5000),
{
"role": "tool",
"name": "capture",
"content": [
{"type": "text", "text": "some result " * 300}, # > 3000 chars
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
],
},
_assistant("a"),
]
out, stats = prune_context(
messages,
budget_tokens=100,
keep_last_turns=0,
max_summarize_chars=100,
)
tool_out = [m for m in out if m.get("role") == "tool"][0]
# The content is still a structured list, not a flattened string.
assert isinstance(tool_out["content"], list)
text_blocks = [
b for b in tool_out["content"]
if isinstance(b, dict) and b.get("type") == "text"
]
assert text_blocks
# Non-empty prose and non-trivial image means it was shrunk (not kept full).
img_blocks = [
b for b in tool_out["content"]
if isinstance(b, dict) and b.get("type") == "image_url"
]
assert len(img_blocks) == 1
# The block survives (type preserved) but its payload is stubbed, so the
# actual shipped bytes drop rather than staying full-size (review #new).
assert img_blocks[0]["image_url"]["url"] == "[image omitted]"
assert img_blocks[0]["image_url"]["url"] != "data:image/png;base64,AAAA"
def test_large_image_tool_result_actually_shrinks_and_stats_are_honest():
# Regression (follow-up review): fixing image_url counting (old #5) and
# preserving non-text blocks (old #11) composed into a case where the text
# block was sliced from prose+base64 joined together and the image_url
# block was copied through UNCHANGED — so the dominant size was never
# reduced while tokens_saved reported a large "saving" that didn't happen,
# and a raw base64 fragment leaked into the text field. The payload must
# genuinely shrink, the image payload must be stubbed, base64 must not
# leak into the text block, and tokens_saved must equal the real shrink.
huge_b64 = "A" * 200000
messages = [
_user("describe this"),
{"role": "assistant", "content": None, "tool_calls": [{"id": "1"}]},
{
"role": "tool",
"name": "screenshot",
"content": [
{"type": "text", "text": "here is the screenshot"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{huge_b64}"}},
],
},
_user("now what"),
]
out, stats = prune_context(messages, budget_tokens=10, keep_last_turns=0)
assert stats["pruned"] is True
# The payload genuinely shrank — final below original.
assert stats["final_tokens"] < stats["original_tokens"]
# Savings equal the real reduction, not a per-message estimate.
assert stats["tokens_saved"] == (
stats["original_tokens"] - stats["final_tokens"]
)
tool_out = [m for m in out if m.get("role") == "tool"][0]
for part in tool_out["content"]:
if isinstance(part, dict) and part.get("type") == "image_url":
assert "AAAA" not in part["image_url"]["url"]
elif isinstance(part, dict) and part.get("type") == "text":
assert "base64" not in part.get("text", "")
def test_pruned_list_measures_smaller_than_the_raw_conversation():
"""Regression for review #4: routing must measure the PRUNED size.
chat_completions used to route on the raw conversation's token count and
only shrink the upstream payload later, so a long tool-heavy session could
be sent to a pricier large-context model based on its pre-pruned size even
though pinch would bring the outgoing request well under budget. This test
pins the invariant that enables the fix: after one prune, the router's own
``estimate_prompt_tokens`` sees a smaller count, so the window/tier/cost
decision reflects what actually ships upstream.
"""
messages = [
{"role": "user", "content": "inspect the repo"},
{"role": "assistant", "content": "reading files", "tool_calls": [{"id": "t1"}]},
{"role": "tool", "name": "read", "content": "file1: " + "x" * 4000},
{"role": "assistant", "content": "still going", "tool_calls": [{"id": "t2"}]},
{"role": "tool", "name": "read", "content": "file2: " + "y" * 4000},
{"role": "assistant", "content": "ok, fixing"},
{"role": "user", "content": "fix it"},
]
raw_tokens = estimate_prompt_tokens(messages)
pruned, stats = prune_context(
list(messages),
budget_tokens=100,
keep_last_turns=0,
max_summarize_chars=4000,
)
assert stats["pruned"] is True
pruned_tokens = estimate_prompt_tokens(pruned)
# The outgoing payload measures strictly smaller than the raw conversation,
# so routing on the pruned list picks the cheaper/smaller-window model.
assert pruned_tokens < raw_tokens
def test_pinch_disabled_leaves_the_measured_size_unchanged():
"""When pinch is off the routed decision measures the full conversation.
Mirrors the byte-for-byte no-op contract: with ``prune_context`` not run,
``estimate_prompt_tokens`` sees the raw messages exactly as before the
reordering fix.
"""
messages = [
{"role": "user", "content": "q"},
{"role": "assistant", "content": "a", "tool_calls": [{"id": "t1"}]},
{"role": "tool", "name": "read", "content": "z" * 4000},
{"role": "user", "content": "done"},
]
# unpinned: identical to calling estimate_prompt_tokens(messages) directly
assert estimate_prompt_tokens(messages) == estimate_prompt_tokens(list(messages))