diff --git a/core/context/src/main/kotlin/com/correx/core/context/builder/DefaultContextPackBuilder.kt b/core/context/src/main/kotlin/com/correx/core/context/builder/DefaultContextPackBuilder.kt index a50cabba..57f411b4 100644 --- a/core/context/src/main/kotlin/com/correx/core/context/builder/DefaultContextPackBuilder.kt +++ b/core/context/src/main/kotlin/com/correx/core/context/builder/DefaultContextPackBuilder.kt @@ -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, ) diff --git a/core/context/src/main/kotlin/com/correx/core/context/compression/ContextClassifier.kt b/core/context/src/main/kotlin/com/correx/core/context/compression/ContextClassifier.kt index 583a3c82..c41d547c 100644 --- a/core/context/src/main/kotlin/com/correx/core/context/compression/ContextClassifier.kt +++ b/core/context/src/main/kotlin/com/correx/core/context/compression/ContextClassifier.kt @@ -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 } diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/PromptRenderer.kt b/core/inference/src/main/kotlin/com/correx/core/inference/PromptRenderer.kt index 790d581a..d940b4d6 100644 --- a/core/inference/src/main/kotlin/com/correx/core/inference/PromptRenderer.kt +++ b/core/inference/src/main/kotlin/com/correx/core/inference/PromptRenderer.kt @@ -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 { - // 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>.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" diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt index 76c8e799..0d44ee89 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt @@ -104,7 +104,8 @@ fun buildGroundingFeedbackEntry(events: List, 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, 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>): 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, ) } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorConcepts.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorConcepts.kt index 88c2ae3e..9756c2b4 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorConcepts.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorConcepts.kt @@ -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, ) } } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorContext.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorContext.kt index 8787ad2b..4d106a0d 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorContext.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorContext.kt @@ -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, 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 { @@ -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, ), ) } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt index 796086f9..e7049ece 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt @@ -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() diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorVerification.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorVerification.kt index 1a9e72c4..4f5f2153 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorVerification.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorVerification.kt @@ -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, ), ) } diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/RemainingDeltaEntryTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/RemainingDeltaEntryTest.kt index 51d27fb7..20107aee 100644 --- a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/RemainingDeltaEntryTest.kt +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/RemainingDeltaEntryTest.kt @@ -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")) diff --git a/testing/deterministic/src/test/kotlin/PromptRendererOrderingTest.kt b/testing/deterministic/src/test/kotlin/PromptRendererOrderingTest.kt index 09650575..4217fbf2 100644 --- a/testing/deterministic/src/test/kotlin/PromptRendererOrderingTest.kt +++ b/testing/deterministic/src/test/kotlin/PromptRendererOrderingTest.kt @@ -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") }) + } } diff --git a/testing/integration/logs/archive/correx-2026-07-21-1.log.gz b/testing/integration/logs/archive/correx-2026-07-21-1.log.gz new file mode 100644 index 00000000..ad1aa111 Binary files /dev/null and b/testing/integration/logs/archive/correx-2026-07-21-1.log.gz differ diff --git a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt index 53fb5104..54c375b2 100644 --- a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt +++ b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt @@ -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}") }