feat(context): required/optional bucket split, budget ceilings, grounded handoffs

Context rework from the 2026-07-05 context-poisoning diagnosis (session
6ca236c4 read-loop):

- ContextBucket on ContextEntry: REQUIRED (task, constraints, needed
  artifacts, failure feedback) is never pruned/compressed/dropped; the
  build fails fast (RequiredContextOverflowException -> non-retryable
  stage failure) if required alone exceeds the stage budget.
- OptionalCeilings: per-sourceType token caps for the optional block
  (repoMap 1200, relevantFiles 1600, journal 800, profiles 400/600,
  vocabulary 400), tuned for the 9-26B tier at 16k; enforced by
  truncation (journal keeps its tail, others their head).
- Repo map is now a structural 'what exists' listing (alphabetical,
  per-directory, no recency ranking, no symbols); .md/docs paths are
  gated behind an explicit docs mention in the stage prompt — retrieval
  hits remain the only other path for docs to enter context.
- file_written needs render as a manifest of ALL files the producing
  stage wrote (projected from ArtifactContentStored -> ToolInvocation
  -> FileWritten events), fixing the last-write-wins collapse; content
  stays lazy behind file_read.
- Decision journal folds a stage's RETRY records into one summary line
  once a later transition leaves that stage (render-time only).
- Dedupe retrieved-file lines in buildRelevantFilesEntry.
This commit is contained in:
2026-07-05 18:04:49 +04:00
parent f90f2ab39d
commit 33ea44d8cc
12 changed files with 545 additions and 40 deletions
@@ -1,10 +1,13 @@
package com.correx.core.journal
import com.correx.core.journal.model.DecisionJournalState
import com.correx.core.journal.model.DecisionKind
import com.correx.core.journal.model.DecisionRecord
class DecisionJournalRenderer(private val keepLast: Int = 40) {
fun render(state: DecisionJournalState, summaryText: String? = null): String {
if (state.records.isEmpty() && summaryText == null) return ""
val visible = foldResolvedRetries(state.records)
return buildString {
appendLine("## Decision history (chronological — ground truth)")
if (summaryText != null) {
@@ -13,7 +16,34 @@ class DecisionJournalRenderer(private val keepLast: Int = 40) {
appendLine("+${state.lowSalienceOmittedCount} routine transitions/retries omitted")
}
}
state.records.takeLast(keepLast).forEach { appendLine("- [${it.kind}] ${it.summary}") }
visible.takeLast(keepLast).forEach { appendLine("- [${it.kind}] ${it.summary}") }
}.trimEnd()
}
/**
* A RETRY record is resolved once a later TRANSITION leaves its stage — the failure was
* overcome and its details are noise for every subsequent stage. Fold each resolved stage's
* retries into one summary line at the position of the first retry; unresolved (still-live)
* retries render verbatim. Render-time only: the journal state and event log are untouched.
*/
private fun foldResolvedRetries(records: List<DecisionRecord>): List<DecisionRecord> {
val resolvedAt = records
.filter { it.kind == DecisionKind.TRANSITION && it.fromStageId != null }
.groupBy { it.fromStageId!! }
.mapValues { (_, transitions) -> transitions.maxOf { it.sequence } }
val foldedStages = mutableSetOf<String>()
return records.mapNotNull { record ->
val stage = record.stageId
val resolution = stage?.let { resolvedAt[it] }
if (record.kind != DecisionKind.RETRY || resolution == null || record.sequence > resolution) {
return@mapNotNull record
}
if (stage in foldedStages) return@mapNotNull null
foldedStages += stage
val count = records.count {
it.kind == DecisionKind.RETRY && it.stageId == stage && it.sequence <= resolution
}
record.copy(summary = "$stage: $count retr${if (count == 1) "y" else "ies"}, resolved — stage completed")
}
}
}
@@ -53,6 +53,7 @@ class DefaultDecisionJournalReducer : DecisionJournalReducer {
kind = DecisionKind.TRANSITION,
summary = "Advanced ${p.from.value}${p.to.value} (${p.transitionId.value})",
stageId = p.to.value,
fromStageId = p.from.value,
)
is RetryAttemptedEvent -> DecisionRecord(
sequence = event.sessionSequence,
@@ -25,4 +25,8 @@ data class DecisionRecord(
val kind: DecisionKind,
val summary: String,
val stageId: String? = null,
// TRANSITION only: the stage the workflow advanced FROM. Lets the renderer tell that a
// stage's earlier RETRY records are resolved (the stage was eventually left successfully)
// and fold them to a single summary line.
val fromStageId: String? = null,
)
@@ -77,4 +77,44 @@ class DecisionJournalRendererTest {
assertTrue(result.contains("+2 routine transitions/retries omitted"))
assertFalse(result.contains("- ["))
}
@Test
fun `resolved retries fold to a single summary line`() {
val state = DecisionJournalState(
records = listOf(
record(1, DecisionKind.RETRY, "Retry 1/3 of scaffold: did not produce").copy(stageId = "scaffold"),
record(2, DecisionKind.RETRY, "Retry 2/3 of scaffold: did not produce").copy(stageId = "scaffold"),
record(3, DecisionKind.TRANSITION, "Advanced scaffold → hook (t1)")
.copy(stageId = "hook", fromStageId = "scaffold"),
),
)
val result = renderer.render(state)
assertFalse(result.contains("did not produce"), "resolved retry detail must not render")
assertTrue(result.contains("- [RETRY] scaffold: 2 retries, resolved — stage completed"))
assertTrue(result.contains("- [TRANSITION] Advanced scaffold → hook"))
}
@Test
fun `unresolved retries render verbatim`() {
val state = DecisionJournalState(
records = listOf(
record(1, DecisionKind.RETRY, "Retry 1/3 of hook: did not produce").copy(stageId = "hook"),
),
)
val result = renderer.render(state)
assertTrue(result.contains("- [RETRY] Retry 1/3 of hook: did not produce"))
}
@Test
fun `retries after the stage was last left are not folded`() {
val state = DecisionJournalState(
records = listOf(
record(1, DecisionKind.TRANSITION, "Advanced scaffold → hook (t1)")
.copy(stageId = "hook", fromStageId = "scaffold"),
record(2, DecisionKind.RETRY, "Retry 1/3 of scaffold: regression").copy(stageId = "scaffold"),
),
)
val result = renderer.render(state)
assertTrue(result.contains("- [RETRY] Retry 1/3 of scaffold: regression"))
}
}