# 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` ```python 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: "` 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) ```python 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=` 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: \n---\nMessage: "` 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": }` 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.