feat(context,kernel): compression pipeline stage 3 — replay-safe tier summarizer
ContextSummarizedEvent (registered) keyed on stable session sequence, not the ephemeral pack ordinal, so replay reuses the recorded summary artifact and never re-calls the LLM (invariants #6/#8). TierContextSummarizer mirrors JournalCompactionService for freeform context (tool outputs/docs/retrieved text) the journal path doesn't cover. ContextState + DefaultContextReducer fold the event; reducer replay-safety unit-tested.
This commit is contained in:
+64
@@ -0,0 +1,64 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.context.model.ContextEntry
|
||||
import com.correx.core.events.events.ContextSummarizedEvent
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.types.SessionId
|
||||
|
||||
/**
|
||||
* Stage 5 (RECURSIVE_SUMMARIZE) for freeform context — the tier the decision-journal
|
||||
* compaction path does NOT cover: long tool outputs, docs and retrieved text. Mirrors
|
||||
* [JournalCompactionService]: summarize via injected inference, store the summary in the
|
||||
* artifact store, emit [ContextSummarizedEvent] referencing it. Replay reads the recorded
|
||||
* artifact and never re-calls the LLM (invariants #6/#8).
|
||||
*/
|
||||
class TierContextSummarizer(
|
||||
private val artifactStore: ArtifactStore,
|
||||
private val summarize: suspend (String) -> String,
|
||||
private val tokenThreshold: () -> Int = { DEFAULT_TOKEN_THRESHOLD },
|
||||
) {
|
||||
companion object {
|
||||
private const val DEFAULT_TOKEN_THRESHOLD = 2000
|
||||
private const val CHARS_PER_TOKEN = 4
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize [freeformEntries] (tier 2/3 candidates) when their combined size crosses the
|
||||
* threshold. Returns true when a summary event was emitted. [tier] selects light summary
|
||||
* (2) vs summary-of-summaries (3); protected spans are already preserved in the fact sheet,
|
||||
* so the prompt only asks to keep intent and load-bearing facts.
|
||||
*/
|
||||
suspend fun summarizeIfNeeded(
|
||||
sessionId: SessionId,
|
||||
freeformEntries: List<ContextEntry>,
|
||||
coversThroughSequence: Long,
|
||||
tier: Int,
|
||||
emit: suspend (EventPayload) -> Unit,
|
||||
): Boolean {
|
||||
if (freeformEntries.isEmpty()) return false
|
||||
val tokenEstimate = freeformEntries.sumOf { it.content.length } / CHARS_PER_TOKEN
|
||||
if (tokenEstimate < tokenThreshold()) return false
|
||||
|
||||
val prompt = buildString {
|
||||
appendLine("Summarize the following context concisely (≤200 words).")
|
||||
appendLine("Preserve user intent, decisions, constraints and every load-bearing fact.")
|
||||
appendLine()
|
||||
freeformEntries.forEach { appendLine("- ${it.content}") }
|
||||
}
|
||||
val summaryText = summarize(prompt)
|
||||
val summaryArtifactId = artifactStore.put(summaryText.toByteArray(Charsets.UTF_8))
|
||||
artifactStore.flushBefore {
|
||||
emit(
|
||||
ContextSummarizedEvent(
|
||||
sessionId = sessionId,
|
||||
coversThroughSequence = coversThroughSequence,
|
||||
summaryArtifactId = summaryArtifactId,
|
||||
tier = tier,
|
||||
entriesOmittedCount = freeformEntries.size,
|
||||
),
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user