feat(context): render retry repair mandate as final user turn (#293)

buildRetryFeedbackEntry was L1/SYSTEM, so PromptRenderer folded it into the
leading system block — far from the assistant/tool transcript and weaker than
the original stage task. Flip it to USER role and give the renderer an explicit
trailing repair-mandate slot (a sourceType set, extensible for recovery later):
repair mandates are lifted out of the inline flow and emitted once as the final
message, after the tool evidence and the steering anchor. retryFeedback is
already in REQUIRED_SOURCE_TYPES so it stays unprunable. Golden renderer test
proves the final message is the repair USER mandate and leading system no longer
carries it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 18:20:59 +04:00
parent f860d1b8af
commit bc050f8e8a
4 changed files with 47 additions and 4 deletions
@@ -21,6 +21,13 @@ data class ChatMessage(
) )
object PromptRenderer { object PromptRenderer {
// #293: gate retry/recovery repair mandates. Root cause: they used to render as L1/SYSTEM, so
// they folded into the leading system block — far from the assistant/tool transcript and weaker
// 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")
// Tiebreak only: when entries carry no chronological ordinal (all 0 — e.g. router // 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 // chat, which assembles its pack directly), fall back to the old layer priority that
// renders L1 (the live user turn) last so the template sees a user query at the end. // renders L1 (the live user turn) last so the template sees a user query at the end.
@@ -42,9 +49,16 @@ object PromptRenderer {
.sortedWith(compareBy({ it.first.ordinal }, { it.second.ordinal })) .sortedWith(compareBy({ it.first.ordinal }, { it.second.ordinal }))
.joinToString("\n\n") { it.second.content } .joinToString("\n\n") { it.second.content }
.takeIf { it.isNotBlank() } .takeIf { it.isNotBlank() }
val conversationMessages = conversationEntries // #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 }
val conversationMessages = inlinePairs
.sortedWith(compareBy({ it.second.ordinal }, { layerPriority(it.first) })) .sortedWith(compareBy({ it.second.ordinal }, { layerPriority(it.first) }))
.map { (_, entry) -> entry.toChatMessage() } .map { (_, entry) -> entry.toChatMessage() }
val repairMandate = repairPairs
.sortedBy { it.second.ordinal }
.joinToString("\n\n") { it.second.content }
.takeIf { it.isNotBlank() }
// Repetition anchoring: steering directives fold into the leading system message, far // 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 // 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 // them once as a trailing user turn, where models attend strongest. Template-safe: a
@@ -57,6 +71,8 @@ object PromptRenderer {
systemContent?.let { add(ChatMessage("system", it)) } systemContent?.let { add(ChatMessage("system", it)) }
addAll(conversationMessages) addAll(conversationMessages)
anchor?.let { add(ChatMessage("user", "Reminder — active steering directive(s):\n$it")) } anchor?.let { add(ChatMessage("user", "Reminder — active steering directive(s):\n$it")) }
// The repair mandate is the final message — the model's next action after the transcript.
repairMandate?.let { add(ChatMessage("user", it)) }
} }
return messages.ifEmpty { listOf(ChatMessage("user", "")) } return messages.ifEmpty { listOf(ChatMessage("user", "")) }
} }
@@ -71,7 +71,9 @@ fun buildRetryFeedbackEntry(events: List<StoredEvent>, stageId: StageId): Contex
sourceType = "retryFeedback", sourceType = "retryFeedback",
sourceId = stageId.value, sourceId = stageId.value,
tokenEstimate = content.length / 4, tokenEstimate = content.length / 4,
role = EntryRole.SYSTEM, // #293: USER (not SYSTEM) so PromptRenderer routes it to the trailing repair-mandate slot —
// the final message after the tool transcript — rather than folding it into leading system.
role = EntryRole.USER,
) )
} }
@@ -53,6 +53,30 @@ class PromptRendererOrderingTest {
Unit Unit
} }
@Test
fun `retry repair mandate renders as the final user turn after the tool transcript`() = kotlinx.coroutines.runBlocking {
// #293: even though the repair mandate is stamped mid-transcript by input order, the renderer
// must lift it to the very end (after assistant/tool evidence) and never fold it into system.
val builder = DefaultContextPackBuilder(DefaultContextCompressor())
val entries = listOf(
entry("sys", ContextLayer.L0, EntryRole.SYSTEM, "systemPrompt"),
entry("task", ContextLayer.L1, EntryRole.USER, "agentPrompt"),
entry("repair", ContextLayer.L1, EntryRole.USER, "retryFeedback"),
entry("call1", ContextLayer.L2, EntryRole.ASSISTANT, "assistantToolCall", sourceId = "tool1"),
entry("result1", ContextLayer.L2, EntryRole.TOOL, "toolResult", sourceId = "tool1"),
)
val pack = builder.build(ContextPackId("p"), sessionId, stageId, entries, TokenBudget(limit = 4000))
val messages = PromptRenderer.render(pack)
assertEquals("user" to "repair", messages.last().role to messages.last().content)
assertEquals(1, messages.count { it.content == "repair" }, "mandate must appear exactly once")
org.junit.jupiter.api.Assertions.assertFalse(
messages.first().content.contains("repair"),
"leading system must not carry the repair mandate",
)
Unit
}
@Test @Test
fun `without ordinals the live user turn still renders last (router chat fallback)`() { fun `without ordinals the live user turn still renders last (router chat fallback)`() {
// Packs built outside DefaultContextPackBuilder (router chat) leave ordinal at 0; // Packs built outside DefaultContextPackBuilder (router chat) leave ordinal at 0;
@@ -50,7 +50,7 @@ class ContextFeedbackTest {
} }
@Test @Test
fun `latest retry event for the stage becomes a labeled L1 system entry`() { fun `latest retry event for the stage becomes a labeled L1 user repair mandate`() {
val events = listOf( val events = listOf(
stored(payload = RetryAttemptedEvent(SessionId("s"), StageId("impl"), 1, 3, "old reason")), stored(payload = RetryAttemptedEvent(SessionId("s"), StageId("impl"), 1, 3, "old reason")),
stored(payload = RetryAttemptedEvent(SessionId("s"), StageId("other"), 1, 3, "other stage")), stored(payload = RetryAttemptedEvent(SessionId("s"), StageId("other"), 1, 3, "other stage")),
@@ -59,7 +59,8 @@ class ContextFeedbackTest {
val entry = buildRetryFeedbackEntry(events, StageId("impl"))!! val entry = buildRetryFeedbackEntry(events, StageId("impl"))!!
assertEquals("retryFeedback", entry.sourceType) assertEquals("retryFeedback", entry.sourceType)
assertEquals(ContextLayer.L1, entry.layer) assertEquals(ContextLayer.L1, entry.layer)
assertEquals(EntryRole.SYSTEM, entry.role) // #293: USER role so it renders as the trailing repair mandate, not folded into leading system.
assertEquals(EntryRole.USER, entry.role)
assertTrue(entry.content.contains("Attempt 2 of 3"), "content: ${entry.content}") assertTrue(entry.content.contains("Attempt 2 of 3"), "content: ${entry.content}")
assertTrue(entry.content.contains("tests failed: NPE in FooTest"), "content: ${entry.content}") assertTrue(entry.content.contains("tests failed: NPE in FooTest"), "content: ${entry.content}")
} }