feat(daemon): attribute stable-prefix drift to the input that caused it - #5802
Merged
Merged
Conversation
`stable_prompt_cache_miss_reason='stable-prompt-changed'` says THAT the cached instruction prefix moved between turns, never WHICH input moved. Diagnosing a drift has meant pairing Langfuse traces by hand and eyeballing two multi-KB prompts for a diff, so the dominant bucket in production -- 4,673 of 6,612 AMR drift events over 14 days, on later-resume turns -- has no explanation telemetry can give. Hash each named input of the prefix per turn, store the map alongside the existing hash on agent_sessions, and diff it when the hash no longer matches. `run_finished` gains `stable_prompt_changed_sections`; the Langfuse turn gains the same field. Attribute over the composer's INPUTS rather than by slicing the composed prompt on its `---` separators: the question a drift has to answer is what CAUSED the prefix to move, and the cause is always an input -- splitting the output would report which bytes differ but still leave the mapping back to a cause to a human. It also keeps prompt assembly untouched. stablePromptHash stays the only source of truth for hit/miss; section hashes never gate a re-send, so a section-table bug can mislabel a drift but never suppress or fabricate one. A drift no tracked section explains reports `unattributed` -- a coverage-gap alarm rather than a silently clean turn, which is what keeps the table honest as new inputs enter the prefix. The composeSystemPrompt argument literal is hoisted to a const and passed unchanged, so one object both composes the prompt and feeds attribution instead of a second hand-maintained copy that would drift from the real inputs and mislabel the telemetry it exists to explain. No prompt bytes change.
PerishCode
approved these changes
Jul 17, 2026
PerishCode
left a comment
Contributor
There was a problem hiding this comment.
@lefarcen This is a clean, well-scoped instrumentation PR — I verified the correctness-sensitive parts end to end and everything holds together.
What I checked:
- Migration safety. The
stable_prompt_sectionscolumn follows the existingPRAGMA table_infoadditive-guard pattern; new DBs get it viaCREATE TABLE, old DBs viaALTER, and the.some(...)guard prevents a double-add. Old rows read back null and degrade tounattributedfor one turn as documented. - SQL binding integrity.
upsertAgentSessionnow has 9 columns / 9 placeholders / 9 bindings in matching order, and every writer (allupsertAgentSessionandpersistCapturedAgentSessioncall sites inserver.ts) passesstablePromptSections, so a stored hash never lands without its section map. - Attribution coherence. The section table's input keys line up with the hoisted
systemPromptInputsliteral plus the two caller-merged inputs (runtimeToolPrompt,clientSystemPrompt), which are exactly the three parts ofstableInstructionFingerprint.stablePromptHashstays the sole re-send decider — section hashes only label a decision already made, so a table bug can mislabel but never suppress or fabricate a drift. The hoist is byte-identical to the prior call argument. - End-to-end plumbing.
changedSectionsflows fromdescribeStablePromptCache→run.promptCache→ the/api/runsserializer (runtimes/runs.tsspread), therun_finishedanalytics event, and the Langfuse turn payload, so the e2e assertions read real production-path data.
The unit + e2e coverage is genuinely load-bearing (determinism, key-order independence, section isolation, appear/disappear, unattributed, malformed-row degradation, and the mutation-tested memory-isolation case). No behavior change, no prompt bytes moved. Nice, careful work — the unattributed-as-coverage-alarm design is a good touch that keeps the table honest as new inputs enter the prefix.
🔁 Powered by Looper · runner=reviewer · agent=claude-code · An autonomous AI dev team for your GitHub repos.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
My use case. I was following up on the 0.14.1 prompt-cache drift investigation (a colleague's write-up + the drift dashboard I built for #622). Both stall at the same wall:
stable_prompt_cache_miss_reason='stable-prompt-changed'tells us that the cached prefix moved, but never which input moved. Every diagnosis so far has been a human pairing Langfuse traces and eyeballing two multi-KB prompts for a diff. That does not scale, and it is the concrete blocker on driving drift down.The pain. Drift is real, large, and worsening every release. AMR, from PostHog:
Absolute drift grew 13.4×: traffic 5.1× × rate 2.6×. Both are real — the rate alone went 15.2% → 40.0%. It worsens monotonically per release:
0.13.0 20.2% → 0.14.0 26.0% → 0.14.1 37.7% → 0.15.0 50.5%.And it is not an AMR story. Over 30 days, by drift rate on resume turns: codex_cli 52.2%, opencode 40.3%, claude_code 36.6%, amr 34.0% — AMR is the best of the four and only ~5.6% of the ~132k total drift events. (The other 15 runtimes are structurally 0: only native-resume runtimes can report drift at all.)
The part that decides scope: over 14 days on AMR, later-resume turns (turn ≥2) carry 4,812 of 6,828 drift events — 71% of the total, despite a much lower rate (27.0% vs 67.2%) than first-resume's smaller base. First-resume drift has a known story (memory extraction sediments new facts mid-session). Nothing in today's telemetry can say what drives the other 4,812. This PR is what makes that question answerable.
This is instrumentation, deliberately not a fix. It ships no behavior change so it can land fast and start collecting; the actual remediations (freezing the memory snapshot at session start being the big one) need that attribution to be targeted and to be verifiable afterwards.
What users will see
Nothing. No UI, no CLI, no prompt bytes, no model-visible change. This adds one diagnostic property to an existing analytics event and one nullable SQLite column.
Two notes for reviewers, since both are questions this PR invites:
composeSystemPrompt({...})argument literal into aconstand passing it — so one object both composes the prompt and feeds attribution, instead of a second hand-maintained copy that would drift from the real inputs and mislabel the very telemetry it exists to explain. The literal is unchanged. See Validation for how this is proven rather than asserted (the obvious guard — the system-prompt snapshot suite — is already red onmain, so it could not carry the claim on its own).odto expose.Surface area
agent_sessions.stable_prompt_sections, guarded by the existingPRAGMA table_infomigration pattern. Old rows read back null and reportunattributedfor exactly one turn, then self-heal on the next write. No backfill, no downgrade hazard (an older daemon ignores the column).Design
Attribution is computed over the composer's inputs, not by slicing the composed prompt on its
---separators. A drift event has to answer "what caused the prefix to move", and the cause is always an input — splitting the output would report which bytes differ but still leave the mapping back to a cause to a human reading Langfuse, which is the manual step this exists to remove. It also means zero risk of touching prompt assembly.prompts/stable-sections.tsholds one table mapping each composer argument to its section (memory,intent,mode,design-system,skill,craft,plugin,instructions,locale,media,critique,mcp,runtime,client-system) — covering thememory / intent / mode / design-system / skill / runtimesplit the investigation asked for.Two properties worth calling out:
stablePromptHashstays the only source of truth for hit/miss. Section hashes never gate a re-send. A bug in the section table can mislabel a drift event but can never suppress or fabricate one.unattributedis a designed outcome, not a failure mode. If the prefix moves and no tracked section did, the input that moved is missing from the table. That is a coverage-gap alarm rather than a silently clean turn — and its rate tells us how good our coverage is. This is what makes the design self-validating: adding a future input to the stable prefix without registering it here shows up in telemetry instead of hiding.The
mcpsection is deliberately dormant: #5336 movedconnectedExternalMcpout of the cached prefix (it reflects live OAuth token validity and flipped mid-conversation). Nothing passes that key today, so the section never reports — it exists so re-admitting a live-token input is attributed on sight rather than rediscovered by hand a third time.memory,intent, and MCP tokens are three instances of one disease: live per-turn state ending up in a prefix that is supposed to be stable.Bug fix verification
Not a bug fix (instrumentation), but the e2e assertions are falsifiable and were verified as load-bearing rather than assumed:
apps/daemon/tests/intent-signal-stable-prompt-cache.test.ts— real daemon, real session resume, real drift:changedSections: ['intent']changedSections: ['memory']and nothing else (the follow-up carries no deck/media/platform vocabulary, so attribution must isolate one cause)changedSections: nullmemoryBodyout of thememorysection turns the memory test red (changedSectionsstops being['memory']). The assertions read real production-path data — they are not vacuously green.Adjacent issues
Found while working here, deliberately not fixed in this PR to keep the diff's answer to "did you change the prompt?" an unambiguous no:
apps/daemon/src/server.tscarries a stale comment abovepromptCoreVariant: "VALIDATION DEFAULT — feat/system-prompt integration branch only … main keeps classic as the default — do NOT carry this flip into a PR against main." feat(prompts): land the slim system-prompt line as the default charter #5603 intentionally landed slim as the default charter on main, so the code is right and the comment now tells the next reader the opposite of the truth. Comment-only fix, worth its own PR.>150, which the investigation correctly flags as traffic-sensitive. Fixed on the dashboard side, not here: ratio as the regression signal, absolute retained as the scoreboard (the goal is 0, and 0 does not scale with traffic).eligibledenominator was wrong, and I fixed it while writing this up.stable_prompt_cache_miss_reason != 'new-session'also matches turns from builds predating this telemetry (≤0.11.0 — 35k AMR turns per 30d), where the property is simply absent. Counting those as eligible inflated every denominator ~2.5× and understated drift accordingly (AMR read 12.7% when it is 34.0%). The honest marker isstable_prompt_cache_hit is set, which is present iff the daemon computed a cache decision. The figures in this PR are the corrected ones. This is a query-side fix only — no code change — but it is worth knowing that published drift rates before today were low by ~2.5×.Validation
pnpm guard— 86/86 passpnpm --filter @open-design/daemon typecheck— cleanpnpm --filter @open-design/daemon test— full daemon suite: 7 failed | 5846 passed | 6 skippedapps/daemon/tests/prompts/stable-sections.test.ts— 18 new unit tests (determinism, key-order independence, section isolation, appearing/disappearing sections,unattributed, malformed-row degradation)apps/daemon/tests/chat-route.test.ts—describeStablePromptCache's existing case updated for the new field, plus 3 new cases (named sections,unattributed, no attribution without a baseline)Byte-identity of the prompt, proven not asserted. The natural guard,
tests/prompts/system-prompt-matrix.test.ts→ "keeps the section gating matrix stable across scenarios", is already failing onmain(stale snapshot, pre-dates this branch), so a green run could not have carried the claim. Instead I compared the snapshot mismatch output itself between baseline and this branch: 271 lines, byte-for-byte identical. The hoist changes no prompt bytes. (Refreshing that stale snapshot is main's problem, not this PR's — touching it here would be exactly the prompt change this PR must be able to deny.)All 7 failures diffed against baseline (
origin/main@1dd813f00) rather than assumed:chat-route+connection-test)launches Kimi connection tests without the legacy acp positional arg,passes keyless BYOK provider config without auth fields to OpenCode,does not leave a pinned assistant message queued when legacy chat fails before spawning,returns signed_out without reading an upstream wallet API,drives representative failed runs through analytics and Langfuse diagnostics,keeps the section gating matrix stable across scenarios.discovers the default tools-dev daemon URL when no sidecar IPC path is available, is a load-dependent flake:tests/daemon-url.test.tsis 5/5 green in isolation on both baseline and this branch.This branch adds 3 passing tests to the two files that already had failures, and nothing new to the failure set.