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
@@ -104,7 +104,8 @@ fun buildGroundingFeedbackEntry(events: List<StoredEvent>, stageId: StageId): Co
sourceType = "groundingFeedback",
sourceId = stageId.value,
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",
sourceId = stageId.value,
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",
sourceId = "remaining-delta",
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",
sourceId = concept.classKey.ifBlank { concept.fingerprint },
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",
sourceId = p.stageId?.value ?: sessionId.value,
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.
// Bare rejections are consolidated separately (buildRejectionFeedbackEntry) so they
@@ -140,18 +143,20 @@ fun buildRejectionFeedbackEntry(events: List<StoredEvent>, stageId: StageId): Co
sourceType = "rejectionFeedback",
sourceId = stageId.value,
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
* SYSTEM entry present in EVERY stage's context (architecture-conformance, 2026-07-14). The intent
* Injects the initial user intent (the freeform request that started the run) as an L1 USER entry
* 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
* 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
* authoritative context. Standing at L0/SYSTEM it is weighted as an instruction and never dropped
* under budget. Absent for fixed-task workflows (no InitialIntentEvent) → empty.
* authoritative context. It is REQUIRED-bucket, so it is never dropped under budget. Absent for
* fixed-task workflows (no InitialIntentEvent) → empty.
*/
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
* 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
* droppable L2 USER turn — matching the mechanics to the stated authority. Correlates each answer's
* "authoritative", so they are pinned as standing context (L0) rather than a droppable L2 turn —
* 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].
*/
@@ -209,7 +215,8 @@ internal suspend fun SessionOrchestrator.buildClarificationAnswerEntries(session
sourceType = "clarificationAnswer",
sourceId = sessionId.value,
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",
sourceId = stageId.value,
tokenEstimate = estimateTokens(bundle),
role = EntryRole.SYSTEM,
// #312: the claim advances as the run progresses — mutable ⇒ USER (stays L0).
role = EntryRole.USER,
),
)
} ?: emptyList()
@@ -86,7 +86,9 @@ internal suspend fun SessionOrchestrator.verifiedBaselineEntries(sessionId: Sess
sourceType = "verifiedBaseline",
sourceId = lastPass.stateKey,
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(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.
assertTrue(entry.content.contains("Remaining to finish this stage"))
assertTrue(entry.content.contains("- [ ] frontend/src/views/TaskView.tsx — exports_default_component"))