Files
6krrt/code_reviews/context-pruning-and-framing-review.md
2026-08-23 22:22:56 -04:00

12 KiB

Review: fbcc636, "context-aware framing and relevance-based context pruning"

What it was reviewing: the two features ported from the MIT-licensed alexrudloff/llmrouter project — context-aware classifier framing (_previous_context / _classifier_user_content in dispatcher.py) and relevance-based context pruning (context_prune.py, "pinch"). Reviewed with /code-review at xhigh effort (10 finder angles + 1-vote verify + a gap sweep), then spot-verified directly: read every file involved, and reproduced the two most severe findings against the actual prune_context function rather than trusting the description.

Verdict: full suite is green (529/529), but that's not the same as correct

pytest passes clean, including the 10 new tests/test_context_prune.py cases. None of those cases exercise the shapes that break, though: a max_summarize_chars below ~3000, a conversation with zero user messages, tool content as image_url blocks, or the dispatcher-level interaction between _previous_context and route(). The suite passing means the happy path works, not that the port is safe on real agent traffic — which is exactly the traffic this router's own README says is dominant (tool calls, long sessions, ~92% cache hits on huge prompts).

Both features are off by default (pinch.enabled: false, classifier.context_framing: true — framing is on, pruning is off), so nothing here is live yet. But context_framing defaulting on means the framing bugs (#1, #2 below) are already affecting every routed /v1/chat/completions call today.

Confirmed bugs — verified directly, not just reported

1. _previous_context feeds the classifier raw tool output, not "the prior turn" — and it's the common case, not an edge case

dispatcher.py:1560-1588

for i in range(len(messages) - 2, -1, -1):
    if messages[i].get("role") == "user":
        continue
    ...

Only role == "user" is skipped while walking backward from messages[-2]. role == "tool" is not. In the standard OpenAI tool-loop shape — assistant(tool_call) -> tool(result) -> user("ok fix it")messages[-2] is the tool message, so _previous_context returns the raw tool-result payload (file contents, grep output, JSON), truncated to 200 chars, as "Context:" for the classifier. This isn't a malformed-conversation edge case; it's what every agent client running a tool loop produces on its very next turn. The function's own docstring says it exists to let the classifier "inherit the [prior] turn's complexity" — it's inheriting arbitrary tool output instead.

2. Same function returns the system prompt as "previous context" on a session's first turn

dispatcher.py:1570-1572

For messages = [system, user], the backward scan starts at index 0 (the system message), which isn't role == "user", so it's not skipped — its first 200 chars come back as the "prior turn." Every fresh session's first message gets classified with "Context: <fragment of the system prompt>" prepended, which for opencode is ~32K chars of tool definitions.

3. Pinch's long-tool-result trim goes negative and inflates the message once max_summarize_chars < 3000

context_prune.py:145-153; config.py:293-299 (no validator on this field, unlike its two siblings)

head = text[:1500]
tail = text[-1500:]
trimmed = len(text) - 3000

This assumes any text reaching this branch is longer than 3000 chars, but the only guard to get here is len(text) > max_summarize_chars, and max_summarize_chars has no lower-bound validator (budget_tokens and keep_last_turns both do). Reproduced directly:

max_summarize_chars=100, tool content = 800 chars
-> pruned tool content = 1632 chars (grew)
-> stats["tokens_saved"] = -734 (negative)
-> marker literally reads "[...-2,200 chars trimmed...]"

Below the default (4000) this can't trigger, but there's nothing stopping an operator from setting it lower, and when they do, pruning does the opposite of its job.

4. Model/tier selection runs on unpruned tokens; pinch's savings never reach the decision that spends the money

dispatcher.py — routing at 1913-1954 vs. prune_context() at 2079

route() is called (twice — see #7) using estimate_prompt_tokens(messages, ...) on the full, unpruned message list, and that's what drives tier selection, the context-window hard filter, and the cost tiebreak. prune_context() doesn't run until line 2079, well after decision.selected is fixed — it only shrinks the payload actually sent to the model already picked. So a long tool-heavy session can get routed to a pricier large-context model based on its pre-pruned size, even though pinch would have brought the real outgoing request in well under budget. This isn't wrong on invalid input, it's an ordering bug: pinch's entire stated purpose (reduce shipped tokens on long sessions) doesn't influence the one decision where that would save money.

5. extract_text silently treats image_url content blocks as empty

context_prune.py:39-53, used for both orig_tokens and the per-message trim decision

Only type == "text" and type == "tool_result" blocks are read; anything else (including image_url) contributes "". Two consequences: token estimates can undercount a session that's actually huge (a tool result full of base64 image data reads as 0 tokens, so orig_tokens may never cross budget_tokens and pruning never triggers), and if pruning does trigger for other reasons, that same message reads as len(text) == 0 <= max_summarize_chars, so the "only replace if the placeholder is shorter" check (23 < 0) is false and the giant blob is left completely untouched while smaller genuine text results nearby get trimmed.

6. TaskRequest.context now has two incompatible meanings sharing one field

dispatcher.py:151-153 (field docstring: "Assembled context (docs/code) to send with the task") vs. the new use in chat_completions (prior conversation turn) vs. config.yaml:395-398 (system prompt instructions written for the second meaning)

/route and /dispatch callers have always been able to pass context as pasted docs/code (dispatch_endpoint splices it verbatim into a system message). The classifier's system prompt was changed globally to say "classify by the CONTEXT's complexity, treating a short message with complex context as inheriting that complexity" — a rule written for the conversational-continuation case, but it now applies unconditionally to every existing context=<pasted code> caller too, since it's the same field and the same prompt. Not obviously wrong, but untested for that existing use and not called out anywhere as a behavior change to it.

Confirmed via reproduction — edge cases in pruning itself

7. context=prev_context is dead weight on the second (measured-context) route() call

dispatcher.py:1942-1954 vs. route()'s branch condition at line 673

route() only reads req.context inside the classify() branch, which is skipped whenever task_category, task_tier, and required_context_tokens are all supplied together — which the reroute at 1942 always does (it copies the first decision's category/tier and sets required_context_tokens=measured). So context=prev_context on that call is passed and never read. Harmless today since the first route() call already consumed it, but it means a future fix to _previous_context (#1/#2 above) would silently not apply here, and there's nothing marking the parameter as inert.

8. dropped doesn't mean dropped

context_prune.py:100-107 (docstring/stat name) vs. 133-144 (actual behavior)

The docstring and config.yaml both say short old tool results are "dropped entirely," and the counter is literally named dropped, but the code never removes a message from the list — it always replaces content with a placeholder string in place. len(pruned) == len(messages) always, regardless of dropped. Any code (or test) later written against the documented contract — e.g. assert len(pruned) == len(messages) - dropped — would be wrong on every request that drops anything.

9. Stale prompt instructions when the framing opt-out is used

config.yaml:395-398

The classifier's system_prompt unconditionally describes the "Context: <prior>\n---\nMessage: <current>" label format, but that layout is only actually produced when classifier.context_framing: true. Set it false (the documented way to get the legacy "task\n\n--- context ---\ncontext" layout) and the classifier still receives instructions describing a format it will never see.

10. Zero user messages -> the recency guard protects nothing

context_prune.py:109-115

Reproduced: with no role == "user" message anywhere in the conversation, num_protected_turns collapses to 0 and protected_from = len(messages), which no real index ever reaches — so the "always keep if i >= protected_from" branch never fires. The single most recent tool result (the one the next turn actually needs) becomes eligible for trimming, same as the oldest one:

messages = [system, assistant(tool_call), tool(20000 chars)]
-> the only tool result gets summarized down, despite being the newest

11. Trimming silently flattens structured content to a plain string

context_prune.py:141, 152

Both trim branches do {**msg, "content": <str>} unconditionally, even when the original content was a list of blocks ([{"type": "text", ...}, {"type": "image_url", ...}]). A tool result that mixes text and an image part loses the image permanently the first time it ages past the protected window — not "trimmed," just gone, with no signal that anything non-text was there.

Cleanup — lower severity, no behavior change

# Location Issue
12 context_prune.py:73-80 _turn_of() has zero call sites anywhere in the repo — leftover from an earlier design.
13 dispatcher.py:1573-1585 _previous_context's content-block extraction duplicates context_prune.extract_text() almost verbatim, despite dispatcher.py:58 already importing from that module (prune_context only). A future change to one won't propagate to the other.
14 dispatcher.py:420-428 vs. 468-481 classify()'s if not context: user_content = task else: user_content = _classifier_user_content(...) duplicates a check _classifier_user_content already makes internally (if not context: return task, line 477). The outer branch can be deleted; classify() can call _classifier_user_content unconditionally.
15 context_prune.py:27-31, config.py:293-299, config.yaml:175-177 Pinch's four defaults and CHARS_PER_TOKEN are each declared independently in two or three places. context_prune.py's own comment admits CHARS_PER_TOKEN "mirrors dispatcher.CHARS_PER_TOKEN" rather than importing it. dispatcher.py always passes cfg.pinch.* explicitly, so the module-level defaults in context_prune.py are dead in production.

Take for next time

The port kept the right invariant (user/assistant/system messages are never touched, only tool results) but re-derived the surrounding plumbing instead of reusing what the codebase already had for it — _previous_context is a second, slightly-different copy of extract_text's block-parsing logic, and it re-introduces exactly the bug _last_user_text next to it was written to avoid (_last_user_text correctly scans for the nearest user message rather than assuming position; _previous_context assumes messages[-2] is meaningful). Both new-feature bugs that actually change routing behavior today (#1, #2) are about that same unguarded assumption: agent traffic doesn't end tidily on a fresh user turn, and this codebase already knows that everywhere else it touches messages.