feat(context): role means message type, layer means pinning (#312/#313)

The system block is now for content that does not change during a run.
Anything the run mutates renders as a user message — both because a
mutating system prefix defeats prompt caching and because models
under-weight system-folded content against the trailing user turn.

PromptRenderer: role alone decides the message type; the old
`layer == L0 ||` clause is gone. That clause was silently overriding
role on four packs (InferenceSummarizer, SemanticReviewerImpl,
CapabilityGapReflectorImpl, Talkie session-naming) which are L0+USER
prompts — they were rendering as a system-only request with no user
turn at all.

Re-roled SYSTEM -> USER, all mutable within a run:
  recoveryTicket, remainingDelta, groundingFeedback, rejectionFeedback
  (trailing slot), plus verifiedBaseline, promotedConcept, claimedTask,
  clarificationAnswer, locked steeringNote, factSheet (inline, still
  L0-pinned).
Left SYSTEM (immutable): systemPrompt, operatingGuidance,
schemaInstruction, projectProfile, operatorProfile, agentInstructions,
successfulPlanShape.

Trailing-slot scarcity guard: repairMandateSourceTypes joined ALL
matches, so a recovery stage on a retry with an unmet delta would stack
three competing mandates. Now a precedence list emits exactly one
(recoveryTicket > retryFeedback > groundingFeedback > rejectionFeedback)
with remainingDelta appended as the completion signal.

ContextClassifier keys STATIC on layer alone — L0 means pinned/never
pruned regardless of role, so the re-roled L0 entries don't fall through
to FREEFORM and get token-pruned.

Also fixes a stale ContextFeedbackTest assertion (expected a CAS hash
the producer deliberately stopped emitting) and a stale initialIntent
doc comment claiming L0/SYSTEM where the code says L1/USER.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 21:10:26 +04:00
parent 514aeae75f
commit a4f6cf0564
12 changed files with 158 additions and 38 deletions
@@ -364,7 +364,10 @@ class DefaultContextPackBuilder(
sourceType = "factSheet", sourceType = "factSheet",
sourceId = "factSheet", sourceId = "factSheet",
tokenEstimate = estimateTokens(content), tokenEstimate = estimateTokens(content),
role = EntryRole.SYSTEM, // #312: re-extracted from the live entry set on EVERY build — the most mutable entry in the
// pack, so it must not sit in the cached system prefix. USER, pinned at L0 with the lowest
// ordinal so it still renders ahead of the transcript.
role = EntryRole.USER,
ordinal = FACT_SHEET_ORDINAL, ordinal = FACT_SHEET_ORDINAL,
) )
@@ -17,9 +17,13 @@ class ContextClassifier {
fun classify(entry: ContextEntry): ContextClass = when { fun classify(entry: ContextEntry): ContextClass = when {
entry.sourceType in STATIC_SOURCES -> ContextClass.STATIC entry.sourceType in STATIC_SOURCES -> ContextClass.STATIC
entry.sourceType in STRUCTURED_SOURCES -> ContextClass.STRUCTURED entry.sourceType in STRUCTURED_SOURCES -> ContextClass.STRUCTURED
// A pinned system directive that isn't one of the known static prompts is still // A pinned L0 directive that isn't one of the known static prompts is still exact-value
// exact-value content — treat as structured (format-compress ok, never prune). // content — never prune it. Keyed on LAYER alone since #312: L0 means "pinned standing
entry.layer == ContextLayer.L0 && entry.role == EntryRole.SYSTEM -> ContextClass.STATIC // context" (a budget/pinning property), while role now means "which chat message type"
// (a rendering property). Several L0 entries are deliberately USER-role now — a mutating
// verified baseline, claimed task, clarification answer — and token-pruning those as
// freeform prose would shred exactly the directives they exist to carry.
entry.layer == ContextLayer.L0 -> ContextClass.STATIC
entry.role == EntryRole.TOOL -> ContextClass.STRUCTURED entry.role == EntryRole.TOOL -> ContextClass.STRUCTURED
else -> ContextClass.FREEFORM else -> ContextClass.FREEFORM
} }
@@ -26,7 +26,23 @@ object PromptRenderer {
// than the original stage task. Instead they render as the FINAL user message, right after the // than the original stage task. Instead they render as the FINAL user message, right after the
// tool evidence, where a weak local model attends strongest and reads it as the next action. // tool evidence, where a weak local model attends strongest and reads it as the next action.
// Add a sourceType here (and set the entry's role to USER) to route it to that trailing slot. // Add a sourceType here (and set the entry's role to USER) to route it to that trailing slot.
private val repairMandateSourceTypes = setOf("retryFeedback") //
// #312: highest precedence FIRST — at most ONE mandate renders per turn. The trailing slot works
// because it is scarce and authoritative; a recovery stage on a retry with an unmet delta would
// otherwise stack three competing "do this next" blocks and the channel becomes noise again.
private val repairMandatePrecedence = listOf(
"recoveryTicket",
"retryFeedback",
"groundingFeedback",
"rejectionFeedback",
)
// The remaining-delta checklist is not a competing mandate — it is the stage's completion
// signal ("what is left to make true") — so it appends after whichever mandate won rather
// than displacing it.
private const val COMPLETION_SIGNAL = "remainingDelta"
private val trailingSourceTypes = repairMandatePrecedence.toSet() + COMPLETION_SIGNAL
// Tiebreak only: when entries carry no chronological ordinal (all 0 — e.g. router // Tiebreak only: when entries carry no chronological ordinal (all 0 — e.g. router
// chat, which assembles its pack directly), fall back to the old layer priority that // chat, which assembles its pack directly), fall back to the old layer priority that
@@ -38,33 +54,48 @@ object PromptRenderer {
} }
fun render(contextPack: ContextPack): List<ChatMessage> { fun render(contextPack: ContextPack): List<ChatMessage> {
// Every SYSTEM-role entry folds into the single leading system message, whatever its // Every SYSTEM-role entry folds into the single leading system message, whatever its layer.
// layer (L0 additionally folds regardless of role). Strict chat templates (e.g. Qwen) // Strict chat templates (e.g. Qwen) reject any system message that is not the first message,
// reject any system message that is not the first message, so recalled memory, stage // so recalled memory, stage summaries, and retrieval entries must never render as standalone
// summaries, and retrieval entries must never render as standalone system turns. // system turns.
//
// #312: ROLE alone decides the message type — the old `layer == L0 ||` clause is gone. The
// system block is for content that does not change during a run (prompts, guidance, profiles);
// anything the run mutates (steering, gate verdicts, tickets, baselines, claimed task) is a
// user message, both because a mutating system prefix defeats prompt caching and because
// models under-weight system-folded content relative to the trailing user turn. Layer keeps
// its own job — budget tier and pin/prune eligibility (see ContextClassifier).
val (systemEntries, conversationEntries) = contextPack.layers.entries val (systemEntries, conversationEntries) = contextPack.layers.entries
.flatMap { (layer, entries) -> entries.map { layer to it } } .flatMap { (layer, entries) -> entries.map { layer to it } }
.partition { (layer, entry) -> layer == ContextLayer.L0 || entry.role == EntryRole.SYSTEM } .partition { (_, entry) -> entry.role == EntryRole.SYSTEM }
val systemContent = systemEntries val systemContent = systemEntries
.sortedWith(compareBy({ it.first.ordinal }, { it.second.ordinal })) .sortedWith(compareBy({ it.first.ordinal }, { it.second.ordinal }))
.joinToString("\n\n") { it.second.content } .joinToString("\n\n") { it.second.content }
.takeIf { it.isNotBlank() } .takeIf { it.isNotBlank() }
// #293: pull repair mandates out of the inline flow — they render once, as the last turn. // #293: pull repair mandates out of the inline flow — they render once, as the last turn.
val (repairPairs, inlinePairs) = conversationEntries val (repairPairs, inlinePairs) = conversationEntries
.partition { it.second.sourceType in repairMandateSourceTypes } .partition { it.second.sourceType in trailingSourceTypes }
val conversationMessages = inlinePairs val conversationMessages = inlinePairs
.sortedWith(compareBy({ it.second.ordinal }, { layerPriority(it.first) })) .sortedWith(compareBy({ it.second.ordinal }, { layerPriority(it.first) }))
.map { (_, entry) -> entry.toChatMessage() } .map { (_, entry) -> entry.toChatMessage() }
val repairMandate = repairPairs // Only the highest-precedence mandate present survives; the rest stay out of the prompt
.sortedBy { it.second.ordinal } // entirely (their content is still in the transcript/event log — this slot is not their
.joinToString("\n\n") { it.second.content } // only carrier). The completion signal appends after it.
val mandate = repairMandatePrecedence.firstNotNullOfOrNull { sourceType ->
repairPairs.contentOf(sourceType)
}
val repairMandate = listOfNotNull(mandate, repairPairs.contentOf(COMPLETION_SIGNAL))
.joinToString("\n\n")
.takeIf { it.isNotBlank() } .takeIf { it.isNotBlank() }
// Repetition anchoring: steering directives fold into the leading system message, far // Repetition anchoring: steering directives render early (they are pinned standing context),
// from the final query — weak local models forget them (lost-in-the-middle). Restate // far from the final query — weak local models forget them (lost-in-the-middle). Restate
// them once as a trailing user turn, where models attend strongest. Template-safe: a // them once as a trailing user turn, where models attend strongest. Template-safe: a
// user message at the end never trips strict system-must-be-first templates. // user message at the end never trips strict system-must-be-first templates. Scans every
val anchor = systemEntries // entry, not just the system fold: since #312 a locked steering note is USER-role too, and
// both the locked and unlocked paths deserve the same anchor.
val anchor = (systemEntries + conversationEntries)
.filter { it.second.sourceType == "steeringNote" } .filter { it.second.sourceType == "steeringNote" }
.sortedBy { it.second.ordinal }
.joinToString("\n") { it.second.content } .joinToString("\n") { it.second.content }
.takeIf { it.isNotBlank() } .takeIf { it.isNotBlank() }
val messages = buildList { val messages = buildList {
@@ -77,6 +108,12 @@ object PromptRenderer {
return messages.ifEmpty { listOf(ChatMessage("user", "")) } return messages.ifEmpty { listOf(ChatMessage("user", "")) }
} }
private fun List<Pair<ContextLayer, ContextEntry>>.contentOf(sourceType: String): String? =
filter { it.second.sourceType == sourceType }
.sortedBy { it.second.ordinal }
.joinToString("\n\n") { it.second.content }
.takeIf { it.isNotBlank() }
private fun ContextEntry.toChatMessage(): ChatMessage = ChatMessage( private fun ContextEntry.toChatMessage(): ChatMessage = ChatMessage(
role = when (role) { role = when (role) {
EntryRole.SYSTEM -> "system" EntryRole.SYSTEM -> "system"
@@ -104,7 +104,8 @@ fun buildGroundingFeedbackEntry(events: List<StoredEvent>, stageId: StageId): Co
sourceType = "groundingFeedback", sourceType = "groundingFeedback",
sourceId = stageId.value, sourceId = stageId.value,
tokenEstimate = content.length / 4, tokenEstimate = content.length / 4,
role = EntryRole.SYSTEM, // #312: a gate verdict is run-state, not standing instruction — USER, trailing slot.
role = EntryRole.USER,
) )
} }
@@ -165,7 +166,10 @@ fun buildRecoveryTicketEntry(events: List<StoredEvent>, stageId: StageId): Conte
sourceType = "recoveryTicket", sourceType = "recoveryTicket",
sourceId = stageId.value, sourceId = stageId.value,
tokenEstimate = content.length / 4, tokenEstimate = content.length / 4,
role = EntryRole.SYSTEM, // #312: the recovery stage exists ONLY because of this ticket, yet as SYSTEM it folded in
// above the whole transcript. It is the same shape as retryFeedback — USER, trailing slot,
// and highest precedence there.
role = EntryRole.USER,
) )
} }
@@ -206,7 +210,11 @@ fun buildRemainingDeltaEntry(items: List<Triple<String, String, String>>): Conte
sourceType = "remainingDelta", sourceType = "remainingDelta",
sourceId = "remaining-delta", sourceId = "remaining-delta",
tokenEstimate = content.length / 4, tokenEstimate = content.length / 4,
role = EntryRole.SYSTEM, // #312: recomputed every turn a write lands — the single most mutable entry in the pack.
// As SYSTEM it both sat in the weakest slot and invalidated the cached system prefix each
// turn. USER, and appended after whichever repair mandate won (it is the completion signal,
// not a competing instruction).
role = EntryRole.USER,
) )
} }
@@ -65,7 +65,9 @@ internal suspend fun SessionOrchestrator.promotedConceptEntries(stageConfig: Sta
sourceType = "promotedConcept", sourceType = "promotedConcept",
sourceId = concept.classKey.ifBlank { concept.fingerprint }, sourceId = concept.classKey.ifBlank { concept.fingerprint },
tokenEstimate = estimateTokens(content), tokenEstimate = estimateTokens(content),
role = EntryRole.SYSTEM, // #312: folded from the whole log including THIS run, so a concept promoted or
// contradicted mid-run changes the set between stages. Mutable ⇒ USER.
role = EntryRole.USER,
) )
} }
} }
@@ -85,7 +85,10 @@ internal suspend fun SessionOrchestrator.buildSteeringNoteEntries(sessionId: Ses
sourceType = "steeringNote", sourceType = "steeringNote",
sourceId = p.stageId?.value ?: sessionId.value, sourceId = p.stageId?.value ?: sessionId.value,
tokenEstimate = estimateTokens(p.content), tokenEstimate = estimateTokens(p.content),
role = EntryRole.SYSTEM, // #312: an operator steering note arrives mid-run by definition — mutable ⇒ USER,
// same as the unlocked path below. Locked notes keep L0 so they stay pinned against
// the budget; PromptRenderer still restates every note as a trailing anchor.
role = EntryRole.USER,
) )
// An operator steering note attached to a decision is a real instruction; keep it. // An operator steering note attached to a decision is a real instruction; keep it.
// Bare rejections are consolidated separately (buildRejectionFeedbackEntry) so they // Bare rejections are consolidated separately (buildRejectionFeedbackEntry) so they
@@ -140,18 +143,20 @@ fun buildRejectionFeedbackEntry(events: List<StoredEvent>, stageId: StageId): Co
sourceType = "rejectionFeedback", sourceType = "rejectionFeedback",
sourceId = stageId.value, sourceId = stageId.value,
tokenEstimate = content.length / 4, tokenEstimate = content.length / 4,
role = EntryRole.SYSTEM, // #312: literally operator voice ("the operator declined"), and it grows mid-stage as more
// calls are rejected — USER, trailing slot.
role = EntryRole.USER,
) )
} }
/** /**
* Injects the initial user intent (the freeform request that started the run) as a pinned L0 * Injects the initial user intent (the freeform request that started the run) as an L1 USER entry
* SYSTEM entry present in EVERY stage's context (architecture-conformance, 2026-07-14). The intent * present in EVERY stage's context (architecture-conformance, 2026-07-14). The intent
* is the single most load-bearing constraint of a run, yet it previously reached normal stages only * is the single most load-bearing constraint of a run, yet it previously reached normal stages only
* as a repo-map retrieval seed (repoKnowledgeQuery) or, on the rare Tier-2 recovery path, the * as a repo-map retrieval seed (repoKnowledgeQuery) or, on the rare Tier-2 recovery path, the
* arbiter ticket — so an implementer could drift from the goal with the goal itself absent from its * arbiter ticket — so an implementer could drift from the goal with the goal itself absent from its
* authoritative context. Standing at L0/SYSTEM it is weighted as an instruction and never dropped * authoritative context. It is REQUIRED-bucket, so it is never dropped under budget. Absent for
* under budget. Absent for fixed-task workflows (no InitialIntentEvent) → empty. * fixed-task workflows (no InitialIntentEvent) → empty.
*/ */
internal suspend fun SessionOrchestrator.buildIntentEntry(sessionId: SessionId): List<ContextEntry> { internal suspend fun SessionOrchestrator.buildIntentEntry(sessionId: SessionId): List<ContextEntry> {
@@ -183,8 +188,9 @@ internal fun SessionOrchestrator.initialIntent(sessionId: SessionId): String? =
/** /**
* Injects the operator's answers to a stage's open questions as a pinned L0 SYSTEM entry, so the * Injects the operator's answers to a stage's open questions as a pinned L0 SYSTEM entry, so the
* stage sees its own questions resolved on the clarification re-run. The prompt calls these answers * stage sees its own questions resolved on the clarification re-run. The prompt calls these answers
* "authoritative", so they are placed as authoritative standing instructions (L0/SYSTEM), not a * "authoritative", so they are pinned as standing context (L0) rather than a droppable L2 turn —
* droppable L2 USER turn — matching the mechanics to the stated authority. Correlates each answer's * matching the mechanics to the stated authority. They render as USER (#312): these are the
* operator's own words, and the set grows with each clarification round. Correlates each answer's
* questionId back to the prompt recorded on the [ClarificationRequestedEvent]. * questionId back to the prompt recorded on the [ClarificationRequestedEvent].
*/ */
@@ -209,7 +215,8 @@ internal suspend fun SessionOrchestrator.buildClarificationAnswerEntries(session
sourceType = "clarificationAnswer", sourceType = "clarificationAnswer",
sourceId = sessionId.value, sourceId = sessionId.value,
tokenEstimate = estimateTokens(content), tokenEstimate = estimateTokens(content),
role = EntryRole.SYSTEM, // #312: operator's own words, and the set grows per clarification round — USER.
role = EntryRole.USER,
), ),
) )
} }
@@ -264,7 +264,8 @@ internal suspend fun SessionOrchestrator.executeStage(
sourceType = "claimedTask", sourceType = "claimedTask",
sourceId = stageId.value, sourceId = stageId.value,
tokenEstimate = estimateTokens(bundle), tokenEstimate = estimateTokens(bundle),
role = EntryRole.SYSTEM, // #312: the claim advances as the run progresses — mutable ⇒ USER (stays L0).
role = EntryRole.USER,
), ),
) )
} ?: emptyList() } ?: emptyList()
@@ -86,7 +86,9 @@ internal suspend fun SessionOrchestrator.verifiedBaselineEntries(sessionId: Sess
sourceType = "verifiedBaseline", sourceType = "verifiedBaseline",
sourceId = lastPass.stateKey, sourceId = lastPass.stateKey,
tokenEstimate = estimateTokens(content), tokenEstimate = estimateTokens(content),
role = EntryRole.SYSTEM, // #312: flips known-good → STALE the moment a write lands, i.e. it changes within a
// single stage. Mutable ⇒ USER (stays L0, so it is still pinned and never pruned).
role = EntryRole.USER,
), ),
) )
} }
@@ -22,7 +22,9 @@ class RemainingDeltaEntryTest {
), ),
)!! )!!
assertEquals("remainingDelta", entry.sourceType) assertEquals("remainingDelta", entry.sourceType)
assertEquals(EntryRole.SYSTEM, entry.role) // #312: USER, not SYSTEM — it is recomputed every turn a write lands, so it must stay out
// of the cached system prefix, and PromptRenderer routes it to the trailing slot.
assertEquals(EntryRole.USER, entry.role)
// Forward-looking framing, not a history of what was done. // Forward-looking framing, not a history of what was done.
assertTrue(entry.content.contains("Remaining to finish this stage")) assertTrue(entry.content.contains("Remaining to finish this stage"))
assertTrue(entry.content.contains("- [ ] frontend/src/views/TaskView.tsx — exports_default_component")) assertTrue(entry.content.contains("- [ ] frontend/src/views/TaskView.tsx — exports_default_component"))
@@ -168,4 +168,57 @@ class PromptRendererOrderingTest {
messages.map { it.role to it.content }, messages.map { it.role to it.content },
) )
} }
@Test
fun `an L0 USER entry renders as a user message, not folded into system`() {
// #312: role alone decides the message type. A mutating L0 entry (verified baseline,
// claimed task, steering note) stays pinned by its layer but must not enter the cached
// system prefix. Also covers the summarizer/reviewer packs, which are L0+USER prompts
// that used to render as a system-only request with no user turn at all.
val pack = ContextPack(
id = ContextPackId("p"),
sessionId = sessionId,
stageId = stageId,
layers = mapOf(
ContextLayer.L0 to listOf(
entry("sys", ContextLayer.L0, EntryRole.SYSTEM, "systemPrompt"),
entry("baseline", ContextLayer.L0, EntryRole.USER, "verifiedBaseline"),
),
),
budgetUsed = 20,
budgetLimit = 4000,
)
assertEquals(
listOf("system" to "sys", "user" to "baseline"),
PromptRenderer.render(pack).map { it.role to it.content },
)
}
@Test
fun `only the highest-precedence repair mandate renders, with the delta appended`() {
// #312 scarcity guard: a recovery stage on a retry with an unmet delta would otherwise
// stack three competing "do this next" blocks and the trailing slot stops being
// authoritative. recoveryTicket outranks retryFeedback; remainingDelta is not a
// competing mandate (it is the completion signal) so it appends rather than displacing.
val pack = ContextPack(
id = ContextPackId("p"),
sessionId = sessionId,
stageId = stageId,
layers = mapOf(
ContextLayer.L1 to listOf(
entry("task", ContextLayer.L1, EntryRole.USER, "agentPrompt"),
entry("retry", ContextLayer.L1, EntryRole.USER, "retryFeedback"),
entry("ticket", ContextLayer.L1, EntryRole.USER, "recoveryTicket"),
entry("delta", ContextLayer.L1, EntryRole.USER, "remainingDelta"),
),
),
budgetUsed = 40,
budgetLimit = 4000,
)
val messages = PromptRenderer.render(pack)
assertEquals("ticket\n\ndelta", messages.last().content)
assertEquals("user", messages.last().role)
// The losing mandate is dropped entirely — it must not leak back into the inline flow.
assertEquals(false, messages.any { it.content.contains("retry") })
}
} }
@@ -38,6 +38,7 @@ import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.testing.fixtures.EventFixtures.stored import com.correx.testing.fixtures.EventFixtures.stored
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -95,10 +96,10 @@ class ContextFeedbackTest {
val entry = buildRetryFeedbackEntry(events, StageId("impl"))!! val entry = buildRetryFeedbackEntry(events, StageId("impl"))!!
assertTrue(entry.content.contains("## Retry repair state"), "content: ${entry.content}") assertTrue(entry.content.contains("## Retry repair state"), "content: ${entry.content}")
assertTrue(entry.content.contains("gate 'execution'"), "content: ${entry.content}") assertTrue(entry.content.contains("gate 'execution'"), "content: ${entry.content}")
assertTrue( // Path only: the raw CAS hash is opaque noise the model can't act on and confuses it into
entry.content.contains("frontend/src/hooks/queries.ts — CAS cafebabe"), // reasoning about hashes — it patches by path via file_read/file_write.
"content: ${entry.content}", assertTrue(entry.content.contains("- frontend/src/hooks/queries.ts"), "content: ${entry.content}")
) assertFalse(entry.content.contains("cafebabe"), "content: ${entry.content}")
assertTrue(entry.content.contains("do NOT re-read"), "content: ${entry.content}") assertTrue(entry.content.contains("do NOT re-read"), "content: ${entry.content}")
} }