feat(context): purify leading SYSTEM, route context layers as USER turns (#290)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -252,7 +252,8 @@ fun buildRelevantFilesEntry(hits: List<RepoKnowledgeHit>): ContextEntry {
|
|||||||
sourceType = "relevantFiles",
|
sourceType = "relevantFiles",
|
||||||
sourceId = "repo-knowledge",
|
sourceId = "repo-knowledge",
|
||||||
tokenEstimate = content.length / 4,
|
tokenEstimate = content.length / 4,
|
||||||
role = EntryRole.SYSTEM,
|
// #290: semantic retrieval hits are L3 reference — USER role, not folded into leading system.
|
||||||
|
role = EntryRole.USER,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
@@ -148,6 +148,9 @@ internal val REQUIRED_SOURCE_TYPES = setOf(
|
|||||||
"retryFeedback",
|
"retryFeedback",
|
||||||
"neededArtifact",
|
"neededArtifact",
|
||||||
"criticFeedback",
|
"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).
|
// HTTP statuses that are transient despite being 4xx (F-002 retry classification).
|
||||||
|
|||||||
+9
-4
@@ -161,12 +161,15 @@ internal suspend fun SessionOrchestrator.buildIntentEntry(sessionId: SessionId):
|
|||||||
return listOf(
|
return listOf(
|
||||||
ContextEntry(
|
ContextEntry(
|
||||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
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,
|
content = content,
|
||||||
sourceType = "initialIntent",
|
sourceType = "initialIntent",
|
||||||
sourceId = sessionId.value,
|
sourceId = sessionId.value,
|
||||||
tokenEstimate = estimateTokens(content),
|
tokenEstimate = estimateTokens(content),
|
||||||
role = EntryRole.SYSTEM,
|
role = EntryRole.USER,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -290,7 +293,8 @@ internal suspend fun SessionOrchestrator.buildRepoMapEntries(sessionId: SessionI
|
|||||||
sourceType = "repoMap",
|
sourceType = "repoMap",
|
||||||
sourceId = "repo-map",
|
sourceId = "repo-map",
|
||||||
tokenEstimate = estimateTokens(content),
|
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",
|
sourceType = "docsCatalog",
|
||||||
sourceId = "docs-catalog",
|
sourceId = "docs-catalog",
|
||||||
tokenEstimate = estimateTokens(content),
|
tokenEstimate = estimateTokens(content),
|
||||||
role = EntryRole.SYSTEM,
|
// #290: docs catalog is L3 reference — USER role so it stays out of leading system.
|
||||||
|
role = EntryRole.USER,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-3
@@ -188,16 +188,26 @@ internal suspend fun SessionOrchestrator.executeStage(
|
|||||||
val journalEntries = if (journalText.isBlank()) emptyList() else listOf(
|
val journalEntries = if (journalText.isBlank()) emptyList() else listOf(
|
||||||
ContextEntry(
|
ContextEntry(
|
||||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
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,
|
content = journalText,
|
||||||
sourceType = "decisionJournal",
|
sourceType = "decisionJournal",
|
||||||
sourceId = "decision-journal",
|
sourceId = "decision-journal",
|
||||||
tokenEstimate = estimateTokens(journalText),
|
tokenEstimate = estimateTokens(journalText),
|
||||||
role = EntryRole.SYSTEM,
|
role = EntryRole.USER,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val repoMapEntries = buildContextualRepoEntries(sessionId, stageId, stageConfig)
|
|
||||||
val sessionEvents = eventStore.read(sessionId)
|
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 criticIds = criticArtifactIds(sessionEvents, graph, stageId)
|
||||||
val criticFrom = sessionEvents
|
val criticFrom = sessionEvents
|
||||||
.mapNotNull { it.payload as? RefinementIterationEvent }
|
.mapNotNull { it.payload as? RefinementIterationEvent }
|
||||||
|
|||||||
@@ -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
|
@Test
|
||||||
fun `non-L0 SYSTEM entries fold into the single leading system message`() {
|
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
|
// Strict chat templates (Qwen) 400 on any system message after index 0. Recalled L3
|
||||||
|
|||||||
@@ -110,7 +110,9 @@ class ToolCallGateTest {
|
|||||||
|
|
||||||
/** A tool that declares an output compressor (strips blank lines) — the Tier-A seam under test. */
|
/** 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 {
|
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 description = "fake compressing tool"
|
||||||
override val parametersSchema: JsonObject = buildJsonObject {}
|
override val parametersSchema: JsonObject = buildJsonObject {}
|
||||||
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
|
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
|
||||||
@@ -374,7 +376,7 @@ class ToolCallGateTest {
|
|||||||
val (orchestrator, _, provider) = buildOrchestrator(executor, FakeCompressingTool(), assessorRule = null)
|
val (orchestrator, _, provider) = buildOrchestrator(executor, FakeCompressingTool(), assessorRule = null)
|
||||||
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
|
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
|
// 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.
|
// (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.
|
// only shapes the derived context entry.
|
||||||
val secondPack = provider.requests[1].contextPack
|
val secondPack = provider.requests[1].contextPack
|
||||||
val toolEntry = secondPack.layers.values.flatten().first { it.sourceType == "toolResult" }
|
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
|
@Test
|
||||||
|
|||||||
@@ -233,14 +233,14 @@ class ContextFeedbackTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `buildRelevantFilesEntry produces L3 system entry with Relevant files header`() {
|
fun `buildRelevantFilesEntry produces L3 user entry with Relevant files header`() {
|
||||||
val hits = listOf(
|
val hits = listOf(
|
||||||
RepoKnowledgeHit(path = "src/Parser.kt", text = "src/Parser.kt: Parser, Lexer", score = 0.9f),
|
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),
|
RepoKnowledgeHit(path = "src/Main.kt", text = "src/Main.kt: main", score = 0.7f),
|
||||||
)
|
)
|
||||||
val entry = buildRelevantFilesEntry(hits)
|
val entry = buildRelevantFilesEntry(hits)
|
||||||
assertEquals(ContextLayer.L3, entry.layer)
|
assertEquals(ContextLayer.L3, entry.layer)
|
||||||
assertEquals(EntryRole.SYSTEM, entry.role)
|
assertEquals(EntryRole.USER, entry.role)
|
||||||
assertEquals("relevantFiles", entry.sourceType)
|
assertEquals("relevantFiles", entry.sourceType)
|
||||||
assertTrue(entry.content.startsWith("## Relevant files"), "content: ${entry.content}")
|
assertTrue(entry.content.startsWith("## Relevant files"), "content: ${entry.content}")
|
||||||
assertTrue(entry.content.contains("src/Parser.kt"), "content: ${entry.content}")
|
assertTrue(entry.content.contains("src/Parser.kt"), "content: ${entry.content}")
|
||||||
|
|||||||
Reference in New Issue
Block a user