plans/ held 58 documents and exactly one said whether it was open. The rest
mixed finished work, reviews of shipped work, parked specs and genuinely
pending ones, with nothing distinguishing them, so "how many plans are in
the queue" had no answer short of reading all 58.
Now `grep -H '^Status:' plans/*.md` is the answer:
50 done 3 in progress 2 planned 2 reference 1 parked
Statuses were derived rather than guessed: CLAUDE.md's own built list and
"What's NOT built yet" section, plus checking the subject exists in the
code. A review of work that shipped counts as done -- it records what was
found, it is not a request for anything. `reference` separates the two docs
that are conventions rather than work items (admin-design-standards,
admin-work-framework), which otherwise read as permanently-open plans.
The vocabulary is deliberately five words. A larger one invites "mostly
done" and "blocked-ish", which is how the directory became unreadable.
test_plans_declare_status.py keeps it from rotting: a new plan without a
marker fails, as does an unknown status, one buried below the eighth line,
or an open status with no reason -- "planned" alone is the state that rots,
since nobody can tell later whether it waits on a decision, a dependency,
or just nobody's turn.
Also updates the sweep plan with what landed and what did not, including
that #9 was not a defect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRQXz5SYZYVWscxS1QqF6U
211 lines
12 KiB
Markdown
211 lines
12 KiB
Markdown
# Review: fbcc636, "context-aware framing and relevance-based context pruning"
|
|
|
|
Status: done -- review of shipped work
|
|
|
|
**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: <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)
|
|
|
|
```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=<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.
|