feat(context,infra): compression pipeline stages 4-5 — token pruning + relevance + ToMe

- build() is now suspend: pipeline runs all stages in fixed order, each gated by level
- TOKEN_PRUNE (level 3): TokenPruner interface + LLMLingua-2 sidecar (sidecars/llmlingua)
  + HttpTokenPruner adapter (fails open if sidecar down); prunes freeform, preserves
  protected spans, skips tier-0 turns when TIER_SPLIT on
- TOME_MERGE (level 8): ToMeMerger collapses near-duplicate freeform turns (Jaccard)
- Stage 5 selection: RelevanceScorer + EmbeddingRelevanceScorer (cosine over Embedder);
  query-conditioned reorder so least-relevant freeform drops first under budget
- [orchestration] compression_level + token_pruner_url config, wired in Main
- suspend ripple fixed across builder callers/stubs
This commit is contained in:
2026-07-01 14:29:56 +04:00
parent e0c222392c
commit 047e2a4070
25 changed files with 509 additions and 65 deletions
@@ -41,7 +41,7 @@ class DefaultContextPackBuilderTest {
)
@Test
fun `oversized tool results from a stage tool loop are trimmed to budget`() {
fun `oversized tool results from a stage tool loop are trimmed to budget`() = kotlinx.coroutines.runBlocking {
// Live repro (2026-06-11): analyst stage with three file_read results of ~5.6k/11.9k/10.7k
// tokens sailed through a 16384 budget untrimmed and produced a 34k-token prompt.
val entries = listOf(
@@ -62,7 +62,7 @@ class DefaultContextPackBuilderTest {
}
@Test
fun `build groups entries by layer`() {
fun `build groups entries by layer`() = kotlinx.coroutines.runBlocking {
val entries = listOf(
entry("a", ContextLayer.L0, 50),
entry("b", ContextLayer.L1, 60),
@@ -75,7 +75,7 @@ class DefaultContextPackBuilderTest {
}
@Test
fun `budgetUsed reflects total token estimate of retained entries`() {
fun `budgetUsed reflects total token estimate of retained entries`() = kotlinx.coroutines.runBlocking {
val entries = listOf(
entry("a", ContextLayer.L0, 100),
entry("b", ContextLayer.L1, 200)
@@ -85,14 +85,14 @@ class DefaultContextPackBuilderTest {
}
@Test
fun `pack budgetUsed never exceeds budgetLimit`() {
fun `pack budgetUsed never exceeds budgetLimit`() = kotlinx.coroutines.runBlocking {
val entries = (1..20).map { entry("e$it", ContextLayer.L2, 100) }
val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 500))
assertTrue(pack.budgetUsed <= 500)
}
@Test
fun `DecisionPointBuilder returns only L0 and L1 entries`() {
fun `DecisionPointBuilder returns only L0 and L1 entries`() = kotlinx.coroutines.runBlocking {
val entries = listOf(
entry("a", ContextLayer.L0, 50),
entry("b", ContextLayer.L1, 60),
@@ -105,7 +105,7 @@ class DefaultContextPackBuilderTest {
}
@Test
fun `build handles empty entries list`() {
fun `build handles empty entries list`() = kotlinx.coroutines.runBlocking {
val pack = builder.build(packId, sessionId, stageId, emptyList(), TokenBudget(limit = 1000))
assertEquals(0, pack.budgetUsed)
assertEquals(0, pack.compressionMetadata.entriesDropped)
@@ -113,7 +113,7 @@ class DefaultContextPackBuilderTest {
}
@Test
fun `steeringNote and eventHistory entries are retained even when exceeding budget`() {
fun `steeringNote and eventHistory entries are retained even when exceeding budget`() = kotlinx.coroutines.runBlocking {
val entries = listOf(
entry("steering", ContextLayer.L0, 500).copy(sourceType = "steeringNote"),
entry("history", ContextLayer.L1, 600).copy(sourceType = "eventHistory"),
@@ -126,7 +126,7 @@ class DefaultContextPackBuilderTest {
}
@Test
fun `tool dialogue keeps chronological order despite sourceType grouping`() {
fun `tool dialogue keeps chronological order despite sourceType grouping`() = kotlinx.coroutines.runBlocking {
// A multi-round tool loop: assistant call then tool result, interleaved. Compression
// groups by sourceType internally; the builder must restore input order so the L2
// layer reads call, result, call, result — not all calls then all results.
@@ -29,7 +29,7 @@ class PromptRendererOrderingTest {
)
@Test
fun `stage tool loop renders chronologically with the task before tool turns`() {
fun `stage tool loop renders chronologically with the task before tool turns`() = kotlinx.coroutines.runBlocking {
// The builder stamps ordinals from input order; the renderer must honour them so the
// model sees: system, task, assistant call, tool result, assistant call, tool result.
val builder = DefaultContextPackBuilder(DefaultContextCompressor())
@@ -49,6 +49,7 @@ class PromptRendererOrderingTest {
"tool" to "result1", "assistant" to "call2", "tool" to "result2"),
messages.map { it.role to it.content },
)
Unit
}
@Test