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,6 +1,8 @@
import com.correx.core.context.builder.DefaultContextPackBuilder
import com.correx.core.context.builder.DefaultDecisionPointBuilder
import com.correx.core.context.compression.DefaultContextCompressor
import com.correx.core.context.builder.RequiredContextOverflowException
import com.correx.core.context.model.ContextBucket
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.TokenBudget
@@ -143,4 +145,50 @@ class DefaultContextPackBuilderTest {
// ordinals are stamped from input order
assertEquals(listOf(1, 2, 3, 4), pack.layers[ContextLayer.L2]!!.map { it.ordinal })
}
@Test
fun `required entries survive intact under budget pressure`() = kotlinx.coroutines.runBlocking {
val entries = listOf(
entry("task", ContextLayer.L1, 400).copy(sourceType = "agentPrompt", bucket = ContextBucket.REQUIRED),
entry("input", ContextLayer.L1, 400).copy(sourceType = "neededArtifact", bucket = ContextBucket.REQUIRED),
entry("map", ContextLayer.L3, 5000).copy(sourceType = "repoMap"),
)
val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 1000))
val retained = pack.layers.values.flatten()
assertTrue(retained.any { it.id == ContextEntryId("task") }, "required task must be retained")
assertTrue(retained.any { it.id == ContextEntryId("input") }, "required input must be retained")
// and retained uncompressed — same token estimate as input
assertEquals(400, retained.first { it.id == ContextEntryId("task") }.tokenEstimate)
}
@Test
fun `build fails fast when required bucket alone exceeds the budget`() {
val entries = listOf(
entry("task", ContextLayer.L1, 900).copy(sourceType = "agentPrompt", bucket = ContextBucket.REQUIRED),
entry("input", ContextLayer.L1, 900).copy(sourceType = "neededArtifact", bucket = ContextBucket.REQUIRED),
)
val thrown = org.junit.jupiter.api.assertThrows<RequiredContextOverflowException> {
kotlinx.coroutines.runBlocking {
builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 1000))
}
}
assertEquals(1800, thrown.requiredTokens)
assertEquals(1000, thrown.budgetLimit)
assertEquals(900, thrown.breakdown["agentPrompt"])
}
@Test
fun `optional groups are capped by their per-type ceiling`() = kotlinx.coroutines.runBlocking {
// Journal ceiling is 800 by default: a 5000-token journal must not survive whole even
// though the overall budget would fit it.
val entries = listOf(
entry("task", ContextLayer.L1, 100).copy(sourceType = "agentPrompt", bucket = ContextBucket.REQUIRED),
entry("journal", ContextLayer.L0, 5000).copy(sourceType = "decisionJournal"),
)
val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 16384))
val journalTokens = pack.layers.values.flatten()
.filter { it.sourceType == "decisionJournal" }
.sumOf { it.tokenEstimate }
assertTrue(journalTokens <= 800, "journal must be capped to 800 tokens, was $journalTokens")
}
}
@@ -423,6 +423,28 @@ class SessionOrchestratorIntegrationTest {
val sessionId = SessionId("s-trunc")
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
// Seed a recorded repo map so the stage context carries an OPTIONAL entry the dropping
// compressor can discard: required entries (like the system prompt) are pinned by the
// bucket split and never reach the compressor.
eventStore.append(
com.correx.core.events.events.NewEvent(
com.correx.core.events.events.EventMetadata(
com.correx.core.events.types.EventId("rm-trunc"),
sessionId,
kotlinx.datetime.Instant.parse("2026-06-04T00:00:00Z"),
1,
null,
null,
),
com.correx.core.events.events.RepoMapComputedEvent(
sessionId,
"/repo",
listOf(com.correx.core.events.events.RepoMapEntry("src/Main.kt", 0.9)),
kotlinx.datetime.Instant.parse("2026-06-04T00:00:00Z"),
),
),
)
truncOrchestrator.run(sessionId, truncGraph, config)
val truncated = eventStore.read(sessionId).mapNotNull { it.payload as? ContextTruncatedEvent }