conversation-identity: the router learns which conversation each request belongs to #99
Reference in New Issue
Block a user
Delete Branch "feat/conversation-identity"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
conversation-identity — the router learns which conversation each request belongs to
This plan makes the router learn which conversation every request belongs to, from the client, instead of guessing from a hash of the system prompt. An opencode plugin (
router-link.js) stamps every request with the opencode session id, the agent name, and the parent session id; the router uses the session id as itssession_key, records the agent/parent onroute_decisions, and letsPOST /outcomeattribute a test result to an exact conversation.Why: on 2026-09-18 one
session_keycarried a ~100k-context main conversation and dozens of short sub-agent conversations at once. Everything session-scoped read a blend: the Wave 2 incumbent lookup, the classifier'ssession_historystep, the cache-rate and switch measurement. This plan is the foundation that un-blends them.The Contract (single home — see docs/api.md for the canonical copy)
Headers, all optional, sent by the client to
POST /v1/chat/completions:X-Router-ConversationsessionID; each sub-agent session has its own)^[A-Za-z0-9._:-]+$X-Router-AgentX-Router-Parent[H2] Duplicate-header semantics: starlette
Headersis a multi-dict —.get()returns the first value for a name and iteration yields lowercased names.resolve_identitytherefore applies first-wins: for a duplicated header name, the first occurrence is used and later ones ignored, whether the input is a realHeadersobject or a plaindict. The lowercased-key normalization makes first-wins well-defined across both input shapes.Rules:
session_key="c:" + <conversation id>whenX-Router-Conversationis valid, otherwisesession_fingerprint(messages)exactly as today. Why the prefix: a fingerprint is 16 hex characters with no colon, so the two namespaces cannot collide.parent_key="c:" + <parent id>whenX-Router-Parentis valid, otherwise NULL.agent= the header value, otherwise NULL. Both stored onroute_decisionsonly.POST /outcomeaccepts an optional body fieldconversation_idwith the conversation-id validation above. Resolution order in_find_outcome_row:request_id, thenconversation_id, thensource, then the unambiguous-most-recent fallback. Theconversation_idstep matchessession_key = 'c:' || <conversation id>first inenergy_observations, then inlocal_energy_observations.session_key, so local-dispatch answers are attributable too. Whenconversation_idis present and matches no row, the result is "not found" (404) — it never falls through tosourceor the fallback.Decision recorded: this plan adds no config knob (header presence is the opt-in; removing the plugin is the off switch, no router restart). The adoption counter is the deliberate exception — its window is config-driven because a counter is observation (read-only), not behavior.
Accepted risk: the router has no auth, so any local client can name any conversation id. Loopback bind is the existing boundary.
Manual acceptance (after merge and restart)
systemctl --user restart llm-router.service(Python changes need it).command cp -f deploy/opencode-plugin/router-link.js ~/.config/opencode/plugins/ && rm ~/.config/opencode/plugins/router-outcome.js, then restart opencode.SELECT session_key, agent, COUNT(*) FROM route_decisions WHERE observed_at > <start> GROUP BY 1,2.Pass = several distinct
c:...keys for one run, each with its own agent, andparent_keyset on the sub-agent rows.client_outcomerow for that conversation and no 409 in the router log.c:keys appear, thechat.headershook is not reaching the openai-compatible provider. The router is unharmed (it fell back to fingerprints); report it, because the plugin then needs a different transport for the headers./metrics(or the admin dashboard) and confirm thec:conversation counter reports a non-zero value after step 3.CLAUDE.md lines proposed (NOT applied — this plan must not edit CLAUDE.md)
Under the Module map / Dispatcher table, add:
Under Other entrypoints, update the plugin note (replaces router-outcome):
And a one-line contract pointer near the module map:
(These are suggestions for the maintainer to apply when merging — they are intentionally left out of this PR's diff.)
Follow-on plans (each waits on this plan landing)
parent_key./metrics, to measure what the 13 OMO agents' system prompts cost (the adoption counter is the seed of this).Adversarial consensus (resolved in the debate; do NOT re-open)
conversation_idis present and matches nothing, return 404. Matches codebase doctrine (report_outcome) and is the load-bearing guarantee against silent misattribution.parent_key: zero-cost nullable column with a real roadmap consumer (reviewer diversity).decision_rowcarries no message text andtui_modelsurfaces only opaque labels, so it does not violate the no-message-text rule.extra='ignore'drops unknownconversation_id), or new router + old plugin (byte-identical fallback). Either order merges independently.Evidence summary
feat/conversation-identity): 2241 passed, 1 failed — baseline was 2186 passed, 1 failed, so +55 new tests, zero new failures, zero new skips.test_incumbent_routing.py::TestDebugLog::test_debug_log_emits_incumbent_identitydate-rot/environmental test that also fails on clean origin/main (seeds 2026-09-13 data outside the trailing cache-rate window on the 2026-09-20 clock). Documented in.omo/evidence/conversation-identity/baseline.txt; not caused by — and not fixed by — this plan.node --test deploy/opencode-plugin/→ 16 tests, 16 pass, 0 fail..omo/evidence/conversation-identity/final.txt.