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:
2026-07-20 18:35:29 +04:00
parent bc050f8e8a
commit 3bf4dd7379
7 changed files with 57 additions and 13 deletions
@@ -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
@@ -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<ToolCapability> = 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
@@ -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}")