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 {
// #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
// 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.
@@ -42,9 +49,16 @@ object PromptRenderer {
.sortedWith(compareBy({ it.first.ordinal }, { it.second.ordinal }))
.joinToString("\n\n") { it.second.content }
.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) }))
.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
// 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
@@ -57,6 +71,8 @@ object PromptRenderer {
systemContent?.let { add(ChatMessage("system", it)) }
addAll(conversationMessages)
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", "")) }
}
@@ -71,7 +71,9 @@ fun buildRetryFeedbackEntry(events: List<StoredEvent>, stageId: StageId): Contex
sourceType = "retryFeedback",
sourceId = stageId.value,
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,
)
}