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
@@ -26,7 +26,23 @@ object PromptRenderer {
// 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.
// 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
// 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> {
// Every SYSTEM-role entry folds into the single leading system message, whatever its
// layer (L0 additionally folds regardless of role). Strict chat templates (e.g. Qwen)
// reject any system message that is not the first message, so recalled memory, stage
// summaries, and retrieval entries must never render as standalone system turns.
// Every SYSTEM-role entry folds into the single leading system message, whatever its layer.
// Strict chat templates (e.g. Qwen) reject any system message that is not the first message,
// so recalled memory, stage summaries, and retrieval entries must never render as standalone
// 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
.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
.sortedWith(compareBy({ it.first.ordinal }, { it.second.ordinal }))
.joinToString("\n\n") { it.second.content }
.takeIf { it.isNotBlank() }
// #293: pull repair mandates out of the inline flow — they render once, as the last turn.
val (repairPairs, inlinePairs) = conversationEntries
.partition { it.second.sourceType in repairMandateSourceTypes }
.partition { it.second.sourceType in trailingSourceTypes }
val conversationMessages = inlinePairs
.sortedWith(compareBy({ it.second.ordinal }, { layerPriority(it.first) }))
.map { (_, entry) -> entry.toChatMessage() }
val repairMandate = repairPairs
.sortedBy { it.second.ordinal }
.joinToString("\n\n") { it.second.content }
// Only the highest-precedence mandate present survives; the rest stay out of the prompt
// entirely (their content is still in the transcript/event log — this slot is not their
// 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() }
// Repetition anchoring: steering directives fold into the leading system message, far
// from the final query — weak local models forget them (lost-in-the-middle). Restate
// Repetition anchoring: steering directives render early (they are pinned standing context),
// 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
// user message at the end never trips strict system-must-be-first templates.
val anchor = systemEntries
// user message at the end never trips strict system-must-be-first templates. Scans every
// 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" }
.sortedBy { it.second.ordinal }
.joinToString("\n") { it.second.content }
.takeIf { it.isNotBlank() }
val messages = buildList {
@@ -77,6 +108,12 @@ object PromptRenderer {
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(
role = when (role) {
EntryRole.SYSTEM -> "system"