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.
386 lines
15 KiB
Python
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))
|