From 3bf4dd7379a96ee3597a4330c61b9d30a4062f4d Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 20 Jul 2026 18:35:29 +0400 Subject: [PATCH] feat(context): purify leading SYSTEM, route context layers as USER turns (#290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move intent, repo map, docs catalog, decision journal and relevant-files context entries to EntryRole.USER so they no longer fold into the single leading SYSTEM block — that block stays pure policy/schema. Omit L3 repo-map retrieval on repair retries (the transcript already carries the evidence). Add "initialIntent" to REQUIRED_SOURCE_TYPES. Co-Authored-By: Claude Opus 4.8 --- .../kernel/orchestration/ContextFeedback.kt | 3 ++- .../orchestration/SessionOrchestrator.kt | 3 +++ .../SessionOrchestratorContext.kt | 13 +++++++---- .../SessionOrchestratorExecution.kt | 16 ++++++++++--- .../test/kotlin/PromptRendererOrderingTest.kt | 23 +++++++++++++++++++ .../src/test/kotlin/ToolCallGateTest.kt | 8 ++++--- .../src/test/kotlin/ContextFeedbackTest.kt | 4 ++-- 7 files changed, 57 insertions(+), 13 deletions(-) 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 49e897c8..6815e7c7 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 @@ -252,7 +252,8 @@ fun buildRelevantFilesEntry(hits: List): ContextEntry { sourceType = "relevantFiles", sourceId = "repo-knowledge", tokenEstimate = content.length / 4, - role = EntryRole.SYSTEM, + // #290: semantic retrieval hits are L3 reference — USER role, not folded into leading system. + role = EntryRole.USER, ) } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index d437760d..fe41cc9f 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -148,6 +148,9 @@ internal val REQUIRED_SOURCE_TYPES = setOf( "retryFeedback", "neededArtifact", "criticFeedback", + // #290: original intent stays unprunable via the REQUIRED bucket now that it renders as + // L1/USER instead of relying on the old L0/SYSTEM never-drop placement. + "initialIntent", ) // HTTP statuses that are transient despite being 4xx (F-002 retry classification). 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 2be06a82..8787ad2b 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 @@ -161,12 +161,15 @@ internal suspend fun SessionOrchestrator.buildIntentEntry(sessionId: SessionId): return listOf( ContextEntry( id = ContextEntryId(UUID.randomUUID().toString()), - layer = ContextLayer.L0, + // #290: the original intent is the operator's request, not Correx policy — render it as + // L1/USER (not folded into leading system). It stays unprunable via the REQUIRED bucket + // (initialIntent ∈ REQUIRED_SOURCE_TYPES), so retention no longer rides on L0/SYSTEM. + layer = ContextLayer.L1, content = content, sourceType = "initialIntent", sourceId = sessionId.value, tokenEstimate = estimateTokens(content), - role = EntryRole.SYSTEM, + role = EntryRole.USER, ), ) } @@ -290,7 +293,8 @@ internal suspend fun SessionOrchestrator.buildRepoMapEntries(sessionId: SessionI sourceType = "repoMap", sourceId = "repo-map", tokenEstimate = estimateTokens(content), - role = EntryRole.SYSTEM, + // #290: L3 retrieval is reference, not policy — USER role so it stays out of leading system. + role = EntryRole.USER, ), ) } @@ -376,7 +380,8 @@ internal suspend fun SessionOrchestrator.buildDocsCatalogEntry(sessionId: Sessio sourceType = "docsCatalog", sourceId = "docs-catalog", tokenEstimate = estimateTokens(content), - role = EntryRole.SYSTEM, + // #290: docs catalog is L3 reference — USER role so it stays out of leading system. + 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 df43d62d..fb7a36b2 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 @@ -188,16 +188,26 @@ internal suspend fun SessionOrchestrator.executeStage( val journalEntries = if (journalText.isBlank()) emptyList() else listOf( ContextEntry( id = ContextEntryId(UUID.randomUUID().toString()), - layer = ContextLayer.L0, + // #290: the decision journal is L3/USER reference (OPTIONAL, ceiling-capped), not policy — + // keep it out of the leading system block that must hold policy/schema/constraints only. + layer = ContextLayer.L3, content = journalText, sourceType = "decisionJournal", sourceId = "decision-journal", tokenEstimate = estimateTokens(journalText), - role = EntryRole.SYSTEM, + role = EntryRole.USER, ), ) - val repoMapEntries = buildContextualRepoEntries(sessionId, stageId, stageConfig) val sessionEvents = eventStore.read(sessionId) + // #290 (acceptance #5): on a gate-repair retry the model must patch the named failure against + // the recorded images, not go re-exploring — so omit the L3 retrieval bundle (repo map, docs + // catalog, semantic hits) on repair turns. Detected by a pending retry mandate for this stage. + val isRepairRetry = buildRetryFeedbackEntry(sessionEvents, stageId) != null + val repoMapEntries = if (isRepairRetry) { + emptyList() + } else { + buildContextualRepoEntries(sessionId, stageId, stageConfig) + } val criticIds = criticArtifactIds(sessionEvents, graph, stageId) val criticFrom = sessionEvents .mapNotNull { it.payload as? RefinementIterationEvent } diff --git a/testing/deterministic/src/test/kotlin/PromptRendererOrderingTest.kt b/testing/deterministic/src/test/kotlin/PromptRendererOrderingTest.kt index 4707ef72..09650575 100644 --- a/testing/deterministic/src/test/kotlin/PromptRendererOrderingTest.kt +++ b/testing/deterministic/src/test/kotlin/PromptRendererOrderingTest.kt @@ -123,6 +123,29 @@ class PromptRendererOrderingTest { ) } + @Test + fun `USER-role context entries render as separate turns and never fold into system`() { + // #290: intent, repo map, docs catalog and decision journal carry EntryRole.USER so the + // leading system block stays pure policy/schema. They must appear as user turns, not merge in. + val pack = ContextPack( + id = ContextPackId("p"), + sessionId = sessionId, + stageId = stageId, + layers = mapOf( + ContextLayer.L0 to listOf(entry("policy", ContextLayer.L0, EntryRole.SYSTEM, "systemPrompt")), + ContextLayer.L1 to listOf(entry("intent", ContextLayer.L1, EntryRole.USER, "initialIntent")), + ContextLayer.L3 to listOf(entry("journal", ContextLayer.L3, EntryRole.USER, "decisionJournal")), + ), + budgetUsed = 30, + budgetLimit = 4000, + ) + val messages = PromptRenderer.render(pack) + assertEquals("policy", messages.first().content, "leading system must be policy only") + org.junit.jupiter.api.Assertions.assertEquals(1, messages.count { it.role == "system" }) + org.junit.jupiter.api.Assertions.assertTrue(messages.any { it.role == "user" && it.content == "intent" }) + org.junit.jupiter.api.Assertions.assertTrue(messages.any { it.role == "user" && it.content == "journal" }) + } + @Test fun `non-L0 SYSTEM entries fold into the single leading system message`() { // Strict chat templates (Qwen) 400 on any system message after index 0. Recalled L3 diff --git a/testing/integration/src/test/kotlin/ToolCallGateTest.kt b/testing/integration/src/test/kotlin/ToolCallGateTest.kt index 1299e954..0b9df468 100644 --- a/testing/integration/src/test/kotlin/ToolCallGateTest.kt +++ b/testing/integration/src/test/kotlin/ToolCallGateTest.kt @@ -110,7 +110,9 @@ class ToolCallGateTest { /** A tool that declares an output compressor (strips blank lines) — the Tier-A seam under test. */ private inner class FakeCompressingTool(override val tier: Tier = Tier.T1) : Tool { - override val name = "file_write" + // Non-write tool: #289 elides successful write receipts before FORMAT_COMPRESS, so this + // must be a generic tool to actually exercise the outputCompressor (blank-line stripping). + override val name = "shell_run" override val description = "fake compressing tool" override val parametersSchema: JsonObject = buildJsonObject {} override val requiredCapabilities: Set = setOf(ToolCapability.FILE_WRITE) @@ -374,7 +376,7 @@ class ToolCallGateTest { val (orchestrator, _, provider) = buildOrchestrator(executor, FakeCompressingTool(), assessorRule = null) val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0)) - orchestrator.run(SessionId("compress"), singleStageGraph(), config) + orchestrator.run(SessionId("compress"), singleStageGraph(setOf("shell_run")), config) // The second inference carries round-1's tool result in its context pack — compressed // (blank lines stripped), not raw, under the uniform `[tool exit=N]` frame the model sees. @@ -382,7 +384,7 @@ class ToolCallGateTest { // only shapes the derived context entry. val secondPack = provider.requests[1].contextPack val toolEntry = secondPack.layers.values.flatten().first { it.sourceType == "toolResult" } - assertEquals("[file_write exit=0]\nline1\nline2\nline3", toolEntry.content) + assertEquals("[shell_run exit=0]\nline1\nline2\nline3", toolEntry.content) } @Test diff --git a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt index c3004758..53fb5104 100644 --- a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt +++ b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt @@ -233,14 +233,14 @@ class ContextFeedbackTest { } @Test - fun `buildRelevantFilesEntry produces L3 system entry with Relevant files header`() { + fun `buildRelevantFilesEntry produces L3 user entry with Relevant files header`() { val hits = listOf( RepoKnowledgeHit(path = "src/Parser.kt", text = "src/Parser.kt: Parser, Lexer", score = 0.9f), RepoKnowledgeHit(path = "src/Main.kt", text = "src/Main.kt: main", score = 0.7f), ) val entry = buildRelevantFilesEntry(hits) assertEquals(ContextLayer.L3, entry.layer) - assertEquals(EntryRole.SYSTEM, entry.role) + assertEquals(EntryRole.USER, entry.role) assertEquals("relevantFiles", entry.sourceType) assertTrue(entry.content.startsWith("## Relevant files"), "content: ${entry.content}") assertTrue(entry.content.contains("src/Parser.kt"), "content: ${entry.content}")