Files
6krrt/plans/context-dual-use-and-classify-once-per-session.md
adlee-was-taken 3523dcf93e docs(plans): give every plan a Status line so the queue is greppable
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
2026-09-08 18:55:16 -04:00

151 lines
8.9 KiB
Markdown

# Spec: two design decisions deferred out of the context-pruning/framing fix pass
Status: done -- session_cache.py
**Origin.** The fix pass for
[`context-pruning-and-framing-review.md`](context-pruning-and-framing-review.md)
(reviewed in
[`context-pruning-and-framing-fixes-review.md`](context-pruning-and-framing-fixes-review.md))
explicitly declined two items as design decisions rather than confirmed
bugs: the `TaskRequest.context` dual-use ambiguity (review finding #6,
partially mitigated but not resolved), and classifying once per session
instead of once per message (an existing item on the project's own "what's
NOT built yet" list). Both are forward-looking — no bug is being reported
here, no code changes accompany this document.
---
## 1. `TaskRequest.context` carries two unrelated meanings
### The problem
`context` was originally, and is still documented as, "assembled context
(docs/code) to send with the task" — a `/route`/`/dispatch` caller pastes
reference material alongside a task description. `chat_completions` now
also uses the same field to carry the prior conversational turn
(`_previous_context`), so a short follow-up like "Yes" inherits that turn's
complexity instead of being classified as trivial in isolation.
The current fix (moving the framing instruction from the static system
prompt into `_classifier_user_content`'s output, appended only when
`context` is non-empty) narrowed the blast radius — the instruction no
longer reaches every classify() call, only ones that actually supply
`context` — but it didn't resolve which of the two meanings a given
`context` value has. A `/route` caller pasting 800 lines of Django code
under `task="Refactor this"` gets the same "a short follow-up continues the
prior turn, classify by the CONTEXT's complexity" instruction that exists
for the conversational case.
### Why this might not need fixing
The instruction's general principle — "short task + large/complex context
implies a non-trivial task" — is arguably a reasonable heuristic for the
docs-paste case too, even though it was written for conversational
follow-ups. It has never been measured against real `/route`/`/dispatch`
traffic with `context` set. This project's own stated epistemics apply
directly here: `POST /outcome` is "the only ground truth," and several
sections of the README describe correcting an assumption only after
measuring it, not before. Speculating about which framing is "more
correct" without a measurement repeats the mistake the project has already
named and moved past.
### Options, if it turns out to matter
| Option | Sketch | Trade-off |
|---|---|---|
| A. Decouple the mechanisms | Keep `TaskRequest.context` as the public docs/code field, untouched. Give `chat_completions`'s conversational-continuation signal its own internal path — e.g. `classify()` gains a private `prior_turn: Optional[str]` parameter distinct from `context`, so the instruction is only ever built for genuine conversational continuations | Cleanest semantically; requires touching `classify()`'s signature and both call sites; `/route`/`/dispatch` behavior is provably unaffected |
| B. Tag the field | Add a `context_kind: Literal["reference", "prior_turn"] = "reference"` field to `TaskRequest`; only `chat_completions`'s internal calls set `"prior_turn"`; the instruction is only appended for that kind | Smaller diff than A; adds a field to the public request model that external callers never need to know about |
| C. Leave as-is, measure | No code change. Watch `route_decisions` (already logs `task_category`/`task_tier`/`source` per request) for `/route`/`/dispatch` calls that supply `context` and see whether their tier/category looks skewed relative to before this change | Zero engineering cost; consistent with the project's own "measure before correcting" pattern; only viable if `/route`/`/dispatch`-with-`context` traffic is common enough to be observable |
**Recommendation:** C first. This codebase already has the instrumentation
(`route_decisions`, `feedback.py` folding in outcomes) to tell whether this
is a real problem instead of a theoretical one, and the existing pattern in
this project — cost-vs-eco, tier-from-price, the whole classifier-model
swap — is "measure, then fix what the measurement shows," not "fix what
looks fishy." If `/route`/`/dispatch`-with-`context` traffic turns out to
be rare or nonexistent, this is not worth A's or B's added surface at all.
---
## 2. Classify once per session, not once per message
> **Settled** — see
> [`session-classification-cache-ttl.md`](session-classification-cache-ttl.md).
> Invalidation (§"Design questions to settle before implementing", item 2)
> is a minutes-based config TTL. The rest of this section is kept for the
> reasoning trail; the successor doc is the one to build from.
### The problem, restated from the README
> ~10s of local overhead on every message is a real tax for an interactive
> agent... Still unaddressed: classify once per session rather than per
> message, cache by prompt hash, or skip classification for short prompts.
With the local `mistral-nemo` classifier this is now ~1.7s/call
(§"The classifier is the latency floor"); with a cloud classifier
(measured against `deepseek-v4-flash`) it's ~1.0s. Either way, every single
turn in a long agent session pays this again, even though the session's
*category* (coding_general, debugging, etc.) rarely changes turn to turn —
what changes is mostly the token count, which `chat_completions` already
measures directly via `estimate_prompt_tokens` and doesn't need the
classifier for.
### The mechanism already half-exists
`route()`'s override branch (`dispatcher.py:673`) already skips
`classify()` entirely whenever `task_category`, `task_tier`, and
`required_context_tokens` are all supplied — this is exactly what the
measured-context reroute (fixed in review finding #4) uses today, just
within a single request. Session-level caching is the same mechanism
applied across requests: classify once, store `(task_category, task_tier)`
keyed by session, and on every later turn in that session call `route()`
with the cached category/tier plus a **freshly measured**
`required_context_tokens` (which is cheap — pure token counting, no model
call) — landing on the override branch and skipping the classifier
round-trip entirely.
### Design questions to settle before implementing
1. **Session identity.** `session_fingerprint`/`session_directory`
(dispatcher.py) already derive a session identity from message content
for observation purposes. Whether that's the right key for a
*classification* cache (vs. e.g. a client-supplied session id, if
opencode's protocol carries one) needs checking — a cache keyed on the
wrong signal either misses constantly (no benefit) or collides across
genuinely different sessions (wrong category persists into unrelated
work).
2. **Invalidation.** A session's task can genuinely change category mid-way
(debugging turns into a docs-writing turn turns into refactoring). Pure
"classify once, cache forever" risks staleness. Candidate triggers to
re-classify: a large jump in `required_context_tokens` between turns (a
proxy for "something new started"), a fixed number of turns (e.g.
re-classify every 20), or a TTL. This needs the same "measure before
deciding" treatment as everything else in this project — a cheap thing
to instrument via `route_decisions.source` (add a `"cached"` value
alongside `"classifier"`/`"override"`/`"fallback"`) and watch category
drift over real sessions before picking a policy.
3. **Interaction with escalation and retries.** `apply_escalation` currently
runs on every fresh classification. A cached category/tier bypasses it
entirely on cache-hit turns — need to decide whether escalation state
should also be cached per-session or re-evaluated each turn (it's cheap,
pure Python, so probably always re-evaluate rather than cache).
4. **Storage.** In-memory dict keyed by session identity is the obvious
starting point (matches the process lifetime of the dispatcher; a
restart just means the next turn in every active session re-classifies
once, which is a safe failure mode) — no new persistence layer needed
unless multi-process deployment becomes a requirement.
### Recommendation
Worth building — the latency case is strong and the mechanism is a small
extension of code that already exists (the override branch) rather than a
new one. But settle invalidation policy (§2) with a measurement pass first,
the same way `context_framing`'s default-on-and-measure and the classifier
model swap were each decided by running both and comparing, not by
argument. A reasonable first cut: cache with no re-classification, ship
behind a config flag (default off, matching every other new-and-unproven
knob in this project — `pinch.enabled`, `min_tool_proficiency`), watch
`route_decisions` on real sessions for category drift, then decide whether
any invalidation trigger is actually needed or whether "classify once,
never again" is good enough in practice.