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:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user