feat(router): Task 3.2 — NarrationTrigger + buildNarrationContext + RouterFacade.narrate

- Add NarrationTrigger(kind, instruction) model to core:router
- Add RouterContextBuilder.buildNarrationContext: minimal ContextPack
  (system + workflow status + L2 + trigger instruction); excludes
  conversationHistory and L3 recalled memory
- Add RouterFacade.narrate: emits RouterNarrationEvent with content,
  latencyMs, tokensUsed; skips event on blank inference; never writes
  ChatTurnEvent or l3MemoryStore.store
- Add RouterNarrationTest covering all acceptance criteria
- Add narrate default to RouterContextBuilder interface so existing
  anonymous test stubs compile without a stub override
This commit is contained in:
2026-06-03 15:52:08 +04:00
parent 432226c544
commit 38b007a6cd
5 changed files with 532 additions and 0 deletions
@@ -11,6 +11,7 @@ import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.Tokenizer
import com.correx.core.router.model.NarrationTrigger
import com.correx.core.router.model.RouterConfig
import com.correx.core.router.model.RouterL2Entry
import com.correx.core.router.model.RouterState
@@ -21,6 +22,17 @@ import kotlinx.coroutines.CancellationException
interface RouterContextBuilder {
suspend fun build(state: RouterState, budget: TokenBudget): ContextPack
/**
* Builds a minimal [ContextPack] for a narration inference call.
* Implementations should include system prompt, workflow status, recent L2, and the trigger
* instruction, but must NOT include conversation history or L3 recalled memory.
*
* Default delegates to [build] so that anonymous test stubs that only override [build] compile
* without needing a stub override.
*/
suspend fun buildNarrationContext(state: RouterState, trigger: NarrationTrigger, budget: TokenBudget): ContextPack =
build(state, budget)
}
class DefaultRouterContextBuilder(
@@ -256,4 +268,101 @@ class DefaultRouterContextBuilder(
private fun fallbackEstimate(content: String): Int {
return (content.length / 4).coerceAtLeast(1)
}
/**
* Builds a minimal [ContextPack] for a single narration inference call.
*
* Contains (in order, subject to [budget]):
* 1. L0 — system prompt (always present)
* 2. L0 — workflow status (always present)
* 3. L2 — recent stage summaries (oldest dropped first when tight)
* 4. L0 — trigger instruction (always present as the final user-facing entry)
*
* Conversation history and L3 recalled memory are intentionally excluded so
* narration lines never enter the conversation flow.
*/
override suspend fun buildNarrationContext(
state: RouterState,
trigger: NarrationTrigger,
budget: TokenBudget,
): ContextPack {
val systemPromptEntry = buildContextEntry(
sourceType = "systemPrompt",
sourceId = "router-system",
content = SYSTEM_PROMPT,
layer = ContextLayer.L0,
role = EntryRole.SYSTEM,
)
val workflowStatusEntry = buildContextEntry(
sourceType = "workflowStatus",
sourceId = state.currentStageId?.value ?: "none",
content = buildWorkflowStatusContent(state),
layer = ContextLayer.L0,
role = EntryRole.SYSTEM,
)
val triggerEntry = buildContextEntry(
sourceType = "narrationTrigger",
sourceId = trigger.kind,
content = trigger.instruction,
layer = ContextLayer.L0,
role = EntryRole.USER,
)
val protectedTokens = systemPromptEntry.tokenEstimate +
workflowStatusEntry.tokenEstimate +
triggerEntry.tokenEstimate
var remainingBudget = (budget.limit - protectedTokens).coerceAtLeast(0)
val allEntries = mutableListOf<ContextEntry>()
allEntries += systemPromptEntry
allEntries += workflowStatusEntry
var droppedCount = 0
val truncatedLayers = mutableSetOf<ContextLayer>()
// L2 stage summaries — newest-to-oldest so oldest get dropped first when tight.
val fittedL2 = mutableListOf<ContextEntry>()
for (l2Entry in state.l2Memory.asReversed()) {
val content = buildL2Content(l2Entry)
val entry = buildContextEntry(
sourceType = "stageSummary",
sourceId = l2Entry.stageId.value,
content = content,
layer = ContextLayer.L2,
role = EntryRole.SYSTEM,
)
if (remainingBudget >= entry.tokenEstimate) {
remainingBudget -= entry.tokenEstimate
fittedL2.add(entry)
} else {
droppedCount++
truncatedLayers.add(ContextLayer.L2)
}
}
fittedL2.reverse()
allEntries.addAll(fittedL2)
// Trigger instruction is always last.
allEntries += triggerEntry
val layers = allEntries
.groupBy { it.layer }
.toSortedMap(compareBy { it.ordinal })
val budgetUsed = allEntries.sumOf { it.tokenEstimate }
return ContextPack(
id = ContextPackId("${state.sessionId?.value ?: "unknown"}-narration-pack"),
sessionId = state.sessionId ?: SessionId("unknown"),
stageId = state.currentStageId ?: StageId.NONE,
layers = layers,
budgetUsed = budgetUsed,
budgetLimit = budget.limit,
compressionMetadata = CompressionMetadata(
appliedStrategies = listOf("L0Immutable", "NarrationContext"),
truncatedLayers = truncatedLayers.toList(),
entriesDropped = droppedCount,
),
)
}
}
@@ -7,6 +7,7 @@ import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.L3MemoryRetrievedEvent
import com.correx.core.events.events.L3RetrievedHit
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.RouterNarrationEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
@@ -22,6 +23,7 @@ import com.correx.core.inference.TokenUsage
import com.correx.core.router.l3.L3MemoryEntry
import com.correx.core.router.l3.L3MemoryStore
import com.correx.core.router.l3.L3Query
import com.correx.core.router.model.NarrationTrigger
import com.correx.core.router.model.RouterConfig
import com.correx.core.router.model.RouterResponse
import kotlinx.coroutines.CancellationException
@@ -37,6 +39,8 @@ interface RouterFacade {
input: String,
mode: ChatMode = ChatMode.CHAT,
): RouterResponse
suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger)
}
class DefaultRouterFacade(
@@ -165,6 +169,52 @@ class DefaultRouterFacade(
return RouterResponse(content = content, steeringEmitted = (mode == ChatMode.STEERING))
}
override suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger) {
val state = routerRepository.getRouterState(sessionId)
val effectiveStageId = state.currentStageId ?: StageId.NONE
val contextPack = routerContextBuilder.buildNarrationContext(state, trigger, config.tokenBudget)
val provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General))
val inferenceRequest = InferenceRequest(
requestId = InferenceRequestId(UUID.randomUUID().toString()),
sessionId = sessionId,
stageId = effectiveStageId,
contextPack = contextPack,
generationConfig = config.generationConfig,
responseFormat = ResponseFormat.Text,
)
val inferenceResponse = provider.infer(inferenceRequest)
val content = inferenceResponse.text
if (content.isBlank()) return
val narrationId = UUID.randomUUID().toString()
val now = Clock.System.now()
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = now,
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = RouterNarrationEvent(
sessionId = sessionId,
narrationId = narrationId,
trigger = trigger.kind,
stageId = effectiveStageId.takeIf { it != StageId.NONE }?.value,
content = content,
latencyMs = inferenceResponse.latencyMs,
tokensUsed = inferenceResponse.tokensUsed,
timestampMs = now.toEpochMilliseconds(),
),
),
)
}
private suspend fun emitChatTurn(
sessionId: SessionId,
content: String,
@@ -0,0 +1,14 @@
package com.correx.core.router.model
/**
* Describes the event or condition that prompted a narration inference call.
*
* The trigger is stored verbatim in [RouterNarrationEvent.trigger] so it can be
* replayed and displayed without re-querying the environment.
*/
data class NarrationTrigger(
/** Short label identifying the trigger kind (e.g. "stage_completed", "tool_executed"). */
val kind: String,
/** Human-readable instruction injected as the final context entry for this narration. */
val instruction: String,
)