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
12 KiB
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
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.