feat(kernel): JournalCompactionService + orchestrator compaction hook + CAS journal rendering
This commit is contained in:
+16
-2
@@ -5,6 +5,7 @@ import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.model.ApprovalDecision
|
||||
import com.correx.core.inference.Tokenizer
|
||||
import com.correx.core.journal.DefaultDecisionJournalRepository
|
||||
import com.correx.core.journal.DecisionJournalRenderer
|
||||
import com.correx.core.artifacts.ArtifactState
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
@@ -50,7 +51,8 @@ class DefaultSessionOrchestrator(
|
||||
private val retryCoordinator: RetryCoordinator,
|
||||
artifactStore: ArtifactStore,
|
||||
tokenizer: Tokenizer? = null,
|
||||
decisionJournalRepository: DefaultDecisionJournalRepository,
|
||||
private val decisionJournalRepository: DefaultDecisionJournalRepository,
|
||||
private val compactionService: JournalCompactionService? = null,
|
||||
) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository), ApprovalGateway {
|
||||
override val tokenizer: Tokenizer? = tokenizer
|
||||
override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> =
|
||||
@@ -294,8 +296,20 @@ class DefaultSessionOrchestrator(
|
||||
SubagentRunRequest(ctx.sessionId, stageId, ctx.graph, ctx.session, ctx.config),
|
||||
).outcome
|
||||
when (result) {
|
||||
is StageExecutionResult.Success ->
|
||||
is StageExecutionResult.Success -> {
|
||||
compactionService?.let { svc ->
|
||||
val journalState = decisionJournalRepository.getJournal(ctx.sessionId)
|
||||
val journalText = DecisionJournalRenderer().render(journalState)
|
||||
val tokenEstimate = journalText.length / 4
|
||||
svc.compactIfNeeded(
|
||||
sessionId = ctx.sessionId,
|
||||
state = journalState,
|
||||
renderedTokenEstimate = tokenEstimate,
|
||||
emit = { payload -> emit(ctx.sessionId, payload) },
|
||||
)
|
||||
}
|
||||
return StepResult.Continue(ctx.copy(stageCount = ctx.stageCount + 1))
|
||||
}
|
||||
|
||||
is StageExecutionResult.Failure -> {
|
||||
log.debug(
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.JournalCompactedEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.journal.model.DecisionJournalState
|
||||
import com.correx.core.journal.model.Salience
|
||||
|
||||
class JournalCompactionService(
|
||||
private val artifactStore: ArtifactStore,
|
||||
private val summarize: suspend (String) -> String,
|
||||
val tokenThreshold: Int = 2000,
|
||||
) {
|
||||
suspend fun compactIfNeeded(
|
||||
sessionId: SessionId,
|
||||
state: DecisionJournalState,
|
||||
renderedTokenEstimate: Int,
|
||||
emit: suspend (EventPayload) -> Unit,
|
||||
): Boolean {
|
||||
if (renderedTokenEstimate < tokenThreshold || state.records.isEmpty()) return false
|
||||
|
||||
val throughSequence = state.records.maxOf { it.sequence }
|
||||
val highRecords = state.records.filter { it.kind.salience() == Salience.HIGH }
|
||||
val lowCount = state.records.count { it.kind.salience() == Salience.LOW }
|
||||
|
||||
val summaryText = if (highRecords.isEmpty()) {
|
||||
"(no high-salience decisions to summarize)"
|
||||
} else {
|
||||
val prompt = buildString {
|
||||
appendLine("Summarize the following key decisions concisely (≤200 words).")
|
||||
appendLine("Preserve all user intent, approvals, steering, and failures.")
|
||||
appendLine()
|
||||
highRecords.forEach { appendLine("- [${it.kind}] ${it.summary}") }
|
||||
}
|
||||
summarize(prompt)
|
||||
}
|
||||
|
||||
val summaryArtifactId = artifactStore.put(summaryText.toByteArray(Charsets.UTF_8))
|
||||
artifactStore.flushBefore {
|
||||
emit(
|
||||
JournalCompactedEvent(
|
||||
sessionId = sessionId,
|
||||
coversThroughSequence = throughSequence,
|
||||
summaryArtifactId = summaryArtifactId,
|
||||
lowSalienceOmittedCount = lowCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -311,9 +311,11 @@ abstract class SessionOrchestrator(
|
||||
// from currentContext.layers (a Map grouped by layer) would scramble turn order
|
||||
// across rounds; instead we grow our own ordered list and let the builder restamp
|
||||
// ordinals from it each round.
|
||||
val journalText = decisionJournalRenderer.render(
|
||||
decisionJournalRepository.getJournal(sessionId),
|
||||
)
|
||||
val journalState = decisionJournalRepository.getJournal(sessionId)
|
||||
val summaryText = journalState.summaryArtifactId?.let { id ->
|
||||
artifactStore.get(id)?.toString(Charsets.UTF_8)
|
||||
}
|
||||
val journalText = decisionJournalRenderer.render(journalState, summaryText)
|
||||
val journalEntries = if (journalText.isBlank()) emptyList() else listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.JournalCompactedEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.journal.model.DecisionJournalState
|
||||
import com.correx.core.journal.model.DecisionKind
|
||||
import com.correx.core.journal.model.DecisionRecord
|
||||
import com.correx.core.utils.TypeId
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class JournalCompactionServiceTest {
|
||||
|
||||
private val fixedArtifactId = TypeId("artifact-001")
|
||||
|
||||
private fun fakeArtifactStore(): ArtifactStore = object : ArtifactStore {
|
||||
override suspend fun put(bytes: ByteArray) = fixedArtifactId
|
||||
override suspend fun get(id: TypeId): ByteArray? = null
|
||||
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
|
||||
}
|
||||
|
||||
private fun stateWithRecords(vararg records: DecisionRecord) =
|
||||
DecisionJournalState(records = records.toList())
|
||||
|
||||
private fun makeRecord(seq: Long, kind: DecisionKind) =
|
||||
DecisionRecord(sequence = seq, kind = kind, summary = "summary of $kind")
|
||||
|
||||
@Test
|
||||
fun `returns false when token estimate is below threshold`(): Unit = runBlocking {
|
||||
val svc = JournalCompactionService(fakeArtifactStore(), { it }, tokenThreshold = 2000)
|
||||
val state = stateWithRecords(makeRecord(1, DecisionKind.INTENT))
|
||||
val emitted = mutableListOf<EventPayload>()
|
||||
val result = svc.compactIfNeeded(SessionId("s1"), state, renderedTokenEstimate = 500) { emitted += it }
|
||||
assertFalse(result)
|
||||
assertTrue(emitted.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns false when records is empty even if estimate is high`(): Unit = runBlocking {
|
||||
val svc = JournalCompactionService(fakeArtifactStore(), { it }, tokenThreshold = 2000)
|
||||
val state = DecisionJournalState(records = emptyList())
|
||||
val emitted = mutableListOf<EventPayload>()
|
||||
val result = svc.compactIfNeeded(SessionId("s1"), state, renderedTokenEstimate = 9999) { emitted += it }
|
||||
assertFalse(result)
|
||||
assertTrue(emitted.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `emits JournalCompactedEvent with correct coversThroughSequence and lowSalienceOmittedCount`(): Unit = runBlocking {
|
||||
val svc = JournalCompactionService(fakeArtifactStore(), { "summary" }, tokenThreshold = 100)
|
||||
val state = stateWithRecords(
|
||||
makeRecord(1, DecisionKind.INTENT), // HIGH
|
||||
makeRecord(2, DecisionKind.TRANSITION), // LOW
|
||||
makeRecord(3, DecisionKind.APPROVAL), // HIGH
|
||||
makeRecord(4, DecisionKind.RETRY), // LOW
|
||||
)
|
||||
val emitted = mutableListOf<EventPayload>()
|
||||
val result = svc.compactIfNeeded(SessionId("s1"), state, renderedTokenEstimate = 500) { emitted += it }
|
||||
assertTrue(result)
|
||||
assertEquals(1, emitted.size)
|
||||
val event = emitted.first() as JournalCompactedEvent
|
||||
assertEquals(4L, event.coversThroughSequence)
|
||||
assertEquals(2, event.lowSalienceOmittedCount)
|
||||
assertEquals(fixedArtifactId, event.summaryArtifactId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only HIGH-salience records are included in the summarize prompt`(): Unit = runBlocking {
|
||||
val capturedPrompts = mutableListOf<String>()
|
||||
val svc = JournalCompactionService(
|
||||
artifactStore = fakeArtifactStore(),
|
||||
summarize = { prompt -> capturedPrompts += prompt; "ok" },
|
||||
tokenThreshold = 100,
|
||||
)
|
||||
val state = stateWithRecords(
|
||||
makeRecord(1, DecisionKind.INTENT), // HIGH
|
||||
makeRecord(2, DecisionKind.TRANSITION), // LOW — should not appear in prompt
|
||||
makeRecord(3, DecisionKind.ARTIFACT), // LOW — should not appear in prompt
|
||||
makeRecord(4, DecisionKind.FAILURE), // HIGH
|
||||
)
|
||||
val emitted = mutableListOf<EventPayload>()
|
||||
svc.compactIfNeeded(SessionId("s1"), state, renderedTokenEstimate = 500) { emitted += it }
|
||||
assertEquals(1, capturedPrompts.size)
|
||||
val prompt = capturedPrompts.first()
|
||||
assertTrue(prompt.contains("INTENT"), "Prompt should contain INTENT")
|
||||
assertTrue(prompt.contains("FAILURE"), "Prompt should contain FAILURE")
|
||||
assertFalse(prompt.contains("TRANSITION"), "Prompt must not contain TRANSITION")
|
||||
assertFalse(prompt.contains("ARTIFACT"), "Prompt must not contain ARTIFACT")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user