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