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
@@ -168,4 +168,57 @@ class PromptRendererOrderingTest {
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.testing.fixtures.EventFixtures.stored
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.assertTrue
import org.junit.jupiter.api.Test
@@ -95,10 +96,10 @@ class ContextFeedbackTest {
val entry = buildRetryFeedbackEntry(events, StageId("impl"))!!
assertTrue(entry.content.contains("## Retry repair state"), "content: ${entry.content}")
assertTrue(entry.content.contains("gate 'execution'"), "content: ${entry.content}")
assertTrue(
entry.content.contains("frontend/src/hooks/queries.ts — CAS cafebabe"),
"content: ${entry.content}",
)
// Path only: the raw CAS hash is opaque noise the model can't act on and confuses it into
// reasoning about hashes — it patches by path via file_read/file_write.
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}")
}