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:
+4
-1
@@ -364,7 +364,10 @@ class DefaultContextPackBuilder(
|
||||
sourceType = "factSheet",
|
||||
sourceId = "factSheet",
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
+7
-3
@@ -17,9 +17,13 @@ class ContextClassifier {
|
||||
fun classify(entry: ContextEntry): ContextClass = when {
|
||||
entry.sourceType in STATIC_SOURCES -> ContextClass.STATIC
|
||||
entry.sourceType in STRUCTURED_SOURCES -> ContextClass.STRUCTURED
|
||||
// A pinned system directive that isn't one of the known static prompts is still
|
||||
// exact-value content — treat as structured (format-compress ok, never prune).
|
||||
entry.layer == ContextLayer.L0 && entry.role == EntryRole.SYSTEM -> ContextClass.STATIC
|
||||
// A pinned L0 directive that isn't one of the known static prompts is still exact-value
|
||||
// content — never prune it. Keyed on LAYER alone since #312: L0 means "pinned standing
|
||||
// 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
|
||||
else -> ContextClass.FREEFORM
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user