feat(kernel): emit ContextTruncatedEvent on workflow context overflow

DefaultContextPackBuilder already enforced the token budget, but SessionOrchestrator discarded the entriesDropped signal at both build sites — overflow was enforced yet unobserved. Emit ContextTruncatedEvent when entriesDropped>0, matching the router path (open-threads 6.2 contract).
This commit is contained in:
2026-06-02 13:58:06 +04:00
parent 97ad456778
commit b976a5c92a
2 changed files with 75 additions and 0 deletions
@@ -23,6 +23,7 @@ import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ContextTruncatedEvent
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent
@@ -267,6 +268,7 @@ abstract class SessionOrchestrator(
entries = systemPrompt + schemaEntries + promptEntries + steeringEntries,
budget = TokenBudget(limit = stageConfig.tokenBudget),
)
emitContextTruncationIfNeeded(sessionId, stageId, contextPack)
var currentContext = contextPack
var inferenceResult = runInference(
@@ -310,6 +312,7 @@ abstract class SessionOrchestrator(
entries = allEntries,
budget = TokenBudget(limit = stageConfig.tokenBudget),
)
emitContextTruncationIfNeeded(sessionId, stageId, currentContext)
inferenceResult = runInference(
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat,
)
@@ -937,6 +940,28 @@ abstract class SessionOrchestrator(
// --- event emission ---
// Surface context-budget overflow as an event on the workflow path. The builder already
// enforces the budget (drops oldest/compressible entries, pinning steering + event history);
// this records that it happened so replay and operators can see it, mirroring the router path.
private suspend fun emitContextTruncationIfNeeded(
sessionId: SessionId,
stageId: StageId,
contextPack: ContextPack,
) {
val meta = contextPack.compressionMetadata
if (meta.entriesDropped <= 0) return
emit(
sessionId,
ContextTruncatedEvent(
sessionId = sessionId,
turnId = stageId.value,
entriesDropped = meta.entriesDropped,
truncatedLayers = meta.truncatedLayers.map { it.name },
timestampMs = Clock.System.now().toEpochMilliseconds(),
),
)
}
internal suspend fun emit(sessionId: SessionId, payload: EventPayload) {
log.debug("[session {}] emitting event, payload: {}", sessionId.value.take(7), payload)
eventStore.append(
@@ -10,7 +10,13 @@ import com.correx.core.approvals.model.ApprovalDecision
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.artifacts.DefaultArtifactReducer
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.context.builder.DefaultContextPackBuilder
import com.correx.core.context.compression.CompressionStrategy
import com.correx.core.context.compression.ContextCompressor
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.TokenBudget
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ContextTruncatedEvent
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.InferenceStartedEvent
import com.correx.core.events.events.OrchestrationPausedEvent
@@ -22,6 +28,7 @@ import com.correx.core.events.execution.RetryPolicy
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.inference.InferenceRepository
import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.InferenceState
@@ -39,6 +46,7 @@ import com.correx.core.sessions.ApprovalMode
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.sessions.projections.replay.EventReplayer
import com.correx.core.transitions.evaluation.PromptResolver
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph
@@ -304,6 +312,48 @@ class SessionOrchestratorIntegrationTest {
assertNotNull(failed.reason)
}
@Test
fun `context budget overflow on the workflow path emits ContextTruncatedEvent`(): Unit = runBlocking {
// A compressor that drops everything forces the builder to report dropped entries; the
// orchestrator must surface that as a ContextTruncatedEvent (mirroring the router path).
val droppingBuilder = DefaultContextPackBuilder(
object : ContextCompressor {
override fun compress(
entries: List<ContextEntry>,
budget: TokenBudget,
strategy: CompressionStrategy,
): List<ContextEntry> = emptyList()
},
)
val truncGraph = WorkflowGraph(
id = "trunc-test",
stages = mapOf(
StageId("A") to StageConfig(metadata = mapOf("systemPrompt" to "you are a helpful assistant")),
),
transitions = setOf(
TransitionEdge(TransitionId("t1"), StageId("A"), StageId("done"), condition = { true }),
),
start = StageId("A"),
)
val truncOrchestrator = DefaultSessionOrchestrator(
repositories = repositories,
// PromptResolver { it } makes the systemPrompt metadata resolve to non-blank text,
// so there is a compressible entry for the dropping compressor to discard.
engines = engines.copy(contextPackBuilder = droppingBuilder, promptResolver = PromptResolver { it }),
retryCoordinator = retryCoordinator,
artifactStore = artifactStore,
)
val sessionId = SessionId("s-trunc")
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
truncOrchestrator.run(sessionId, truncGraph, config)
val truncated = eventStore.read(sessionId).mapNotNull { it.payload as? ContextTruncatedEvent }
assertTrue(truncated.isNotEmpty(), "expected a ContextTruncatedEvent when the budget drops entries")
assertEquals("A", truncated.first().turnId)
assertTrue(truncated.first().entriesDropped >= 1)
}
@Test
fun `artifactStore put is called for prompt and response and ids appear on inference events`(): Unit = runBlocking {
val recordingStore = RecordingArtifactStore()