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:
@@ -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,
|
||||
)
|
||||
@@ -647,6 +647,8 @@ class RouterFacadeTest {
|
||||
object : RouterFacade {
|
||||
override suspend fun onUserInput(sessionId: SessionId, input: String, mode: ChatMode): RouterResponse =
|
||||
impl.onUserInput(sessionId = sessionId, input = input, mode = chatMode)
|
||||
override suspend fun narrate(sessionId: SessionId, trigger: com.correx.core.router.model.NarrationTrigger) =
|
||||
impl.narrate(sessionId = sessionId, trigger = trigger)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import com.correx.core.context.model.ContextLayer
|
||||
import com.correx.core.context.model.ContextPack
|
||||
import com.correx.core.context.model.TokenBudget
|
||||
import com.correx.core.events.events.ChatTurnEvent
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.RouterNarrationEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.CapabilityScore
|
||||
import com.correx.core.inference.Embedder
|
||||
import com.correx.core.inference.FinishReason
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.InferenceRequest
|
||||
import com.correx.core.inference.InferenceResponse
|
||||
import com.correx.core.inference.InferenceRouter
|
||||
import com.correx.core.inference.ModelCapability
|
||||
import com.correx.core.inference.NoopEmbedder
|
||||
import com.correx.core.inference.ProviderHealth
|
||||
import com.correx.core.inference.ResponseFormat
|
||||
import com.correx.core.inference.TokenUsage
|
||||
import com.correx.core.router.DefaultRouterContextBuilder
|
||||
import com.correx.core.router.DefaultRouterFacade
|
||||
import com.correx.core.router.RouterContextBuilder
|
||||
import com.correx.core.router.RouterRepository
|
||||
import com.correx.core.router.l3.InMemoryL3MemoryStore
|
||||
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.RouterState
|
||||
import com.correx.core.router.model.WorkflowStatus
|
||||
import com.correx.testing.fixtures.inference.MockTokenizer
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class RouterNarrationTest {
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// narrate() — event emission
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `narrate emits RouterNarrationEvent with inference content and metrics`(): Unit = runBlocking {
|
||||
val knownLatencyMs = 55L
|
||||
val knownUsage = TokenUsage(promptTokens = 8, completionTokens = 3)
|
||||
val eventStore = MapBackedEventStore()
|
||||
val facade = buildFacade(
|
||||
eventStore = eventStore,
|
||||
responseText = "narration line",
|
||||
latencyMs = knownLatencyMs,
|
||||
tokensUsed = knownUsage,
|
||||
)
|
||||
val trigger = NarrationTrigger(kind = "stage_completed", instruction = "Summarise what just happened.")
|
||||
|
||||
facade.narrate(SessionId("sess-1"), trigger)
|
||||
|
||||
val narrationEvents = eventStore.appendedEvents.map { it.payload }.filterIsInstance<RouterNarrationEvent>()
|
||||
assertEquals(1, narrationEvents.size)
|
||||
val event = narrationEvents[0]
|
||||
assertEquals("narration line", event.content)
|
||||
assertEquals(knownLatencyMs, event.latencyMs)
|
||||
assertNotNull(event.tokensUsed)
|
||||
assertEquals(11, event.tokensUsed!!.totalTokens)
|
||||
assertEquals("stage_completed", event.trigger)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `narrate does not emit ChatTurnEvent`(): Unit = runBlocking {
|
||||
val eventStore = MapBackedEventStore()
|
||||
val facade = buildFacade(eventStore = eventStore, responseText = "narration text")
|
||||
val trigger = NarrationTrigger(kind = "tool_executed", instruction = "Describe the tool result.")
|
||||
|
||||
facade.narrate(SessionId("sess-2"), trigger)
|
||||
|
||||
val chatEvents = eventStore.appendedEvents.map { it.payload }.filterIsInstance<ChatTurnEvent>()
|
||||
assertTrue(chatEvents.isEmpty(), "narrate must not emit any ChatTurnEvent")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `narrate does not write to l3MemoryStore`(): Unit = runBlocking {
|
||||
val trackingL3 = TrackingL3MemoryStore()
|
||||
val facade = buildFacade(
|
||||
l3MemoryStore = trackingL3,
|
||||
responseText = "narration output",
|
||||
)
|
||||
val trigger = NarrationTrigger(kind = "stage_completed", instruction = "Tell the user.")
|
||||
|
||||
facade.narrate(SessionId("sess-3"), trigger)
|
||||
|
||||
assertTrue(trackingL3.storedEntries.isEmpty(), "narrate must not call l3MemoryStore.store")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `narrate skips event emission when inference returns blank`(): Unit = runBlocking {
|
||||
val eventStore = MapBackedEventStore()
|
||||
val facade = buildFacade(eventStore = eventStore, responseText = " ")
|
||||
val trigger = NarrationTrigger(kind = "stage_completed", instruction = "Say something.")
|
||||
|
||||
facade.narrate(SessionId("sess-4"), trigger)
|
||||
|
||||
val narrationEvents = eventStore.appendedEvents.map { it.payload }.filterIsInstance<RouterNarrationEvent>()
|
||||
assertTrue(narrationEvents.isEmpty(), "blank inference must not emit RouterNarrationEvent")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `narrate skips event emission when inference returns empty string`(): Unit = runBlocking {
|
||||
val eventStore = MapBackedEventStore()
|
||||
val facade = buildFacade(eventStore = eventStore, responseText = "")
|
||||
val trigger = NarrationTrigger(kind = "tool_executed", instruction = "Narrate.")
|
||||
|
||||
facade.narrate(SessionId("sess-5"), trigger)
|
||||
|
||||
val narrationEvents = eventStore.appendedEvents.map { it.payload }.filterIsInstance<RouterNarrationEvent>()
|
||||
assertTrue(narrationEvents.isEmpty(), "empty inference must not emit RouterNarrationEvent")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `narrate RouterNarrationEvent carries sessionId and stageId from state`(): Unit = runBlocking {
|
||||
val stageId = StageId("my-stage")
|
||||
val eventStore = MapBackedEventStore()
|
||||
val facade = DefaultRouterFacade(
|
||||
routerRepository = object : RouterRepository {
|
||||
override suspend fun getRouterState(sessionId: SessionId): RouterState =
|
||||
RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
)
|
||||
},
|
||||
routerContextBuilder = object : RouterContextBuilder {
|
||||
override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack()
|
||||
override suspend fun buildNarrationContext(state: RouterState, trigger: NarrationTrigger, budget: TokenBudget): ContextPack = emptyContextPack()
|
||||
},
|
||||
inferenceRouter = mockInferenceRouter("stage narration", latencyMs = 10L, tokensUsed = TokenUsage(1, 1)),
|
||||
eventStore = eventStore,
|
||||
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
|
||||
embedder = NoopEmbedder(dimension = 8),
|
||||
l3MemoryStore = InMemoryL3MemoryStore(),
|
||||
)
|
||||
val trigger = NarrationTrigger(kind = "stage_completed", instruction = "Describe the stage.")
|
||||
|
||||
facade.narrate(SessionId("session-x"), trigger)
|
||||
|
||||
val narrationEvents = eventStore.appendedEvents.map { it.payload }.filterIsInstance<RouterNarrationEvent>()
|
||||
assertEquals(1, narrationEvents.size)
|
||||
assertEquals(SessionId("session-x"), narrationEvents[0].sessionId)
|
||||
assertEquals("my-stage", narrationEvents[0].stageId)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// buildNarrationContext — content structure
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `buildNarrationContext includes system entry in L0`(): Unit = runBlocking {
|
||||
val builder = DefaultRouterContextBuilder(RouterConfig())
|
||||
val state = RouterState(sessionId = SessionId("s"), workflowStatus = WorkflowStatus.IDLE)
|
||||
val trigger = NarrationTrigger(kind = "test", instruction = "Do something.")
|
||||
val pack = builder.buildNarrationContext(state, trigger, TokenBudget(limit = 5000))
|
||||
|
||||
val l0 = pack.layers[ContextLayer.L0]
|
||||
assertNotNull(l0, "L0 layer must be present")
|
||||
assertTrue(l0!!.isNotEmpty(), "L0 must contain at least the system prompt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildNarrationContext includes workflow status entry`(): Unit = runBlocking {
|
||||
val builder = DefaultRouterContextBuilder(RouterConfig())
|
||||
val state = RouterState(
|
||||
sessionId = SessionId("s"),
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = StageId("my-stage"),
|
||||
)
|
||||
val trigger = NarrationTrigger(kind = "test", instruction = "Describe.")
|
||||
val pack = builder.buildNarrationContext(state, trigger, TokenBudget(limit = 5000))
|
||||
|
||||
val l0 = pack.layers[ContextLayer.L0]
|
||||
assertNotNull(l0)
|
||||
val allContent = l0!!.joinToString(" ") { it.content }
|
||||
assertTrue(allContent.contains("my-stage"), "workflow status must mention the current stage")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildNarrationContext includes trigger instruction as final entry`(): Unit = runBlocking {
|
||||
val builder = DefaultRouterContextBuilder(RouterConfig())
|
||||
val state = RouterState(sessionId = SessionId("s"), workflowStatus = WorkflowStatus.IDLE)
|
||||
val instruction = "Narrate the completed action now."
|
||||
val trigger = NarrationTrigger(kind = "stage_completed", instruction = instruction)
|
||||
val pack = builder.buildNarrationContext(state, trigger, TokenBudget(limit = 5000))
|
||||
|
||||
val allEntries = pack.layers.values.flatten()
|
||||
assertTrue(
|
||||
allEntries.any { it.content.contains(instruction) },
|
||||
"trigger instruction must appear in narration context",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildNarrationContext stays within budget`(): Unit = runBlocking {
|
||||
val builder = DefaultRouterContextBuilder(RouterConfig())
|
||||
val state = RouterState(sessionId = SessionId("s"), workflowStatus = WorkflowStatus.IDLE)
|
||||
val trigger = NarrationTrigger(kind = "test", instruction = "Short.")
|
||||
val budget = TokenBudget(limit = 5000)
|
||||
val pack = builder.buildNarrationContext(state, trigger, budget)
|
||||
|
||||
assertTrue(pack.budgetUsed <= budget.limit, "narration context must not exceed budget")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildNarrationContext does not include conversationHistory turns`(): Unit = runBlocking {
|
||||
val builder = DefaultRouterContextBuilder(RouterConfig())
|
||||
val state = RouterState(
|
||||
sessionId = SessionId("s"),
|
||||
workflowStatus = WorkflowStatus.IDLE,
|
||||
conversationHistory = listOf(
|
||||
com.correx.core.router.model.RouterTurn(
|
||||
role = com.correx.core.router.model.TurnRole.USER,
|
||||
content = "secret conversation turn",
|
||||
timestamp = kotlinx.datetime.Clock.System.now(),
|
||||
),
|
||||
),
|
||||
)
|
||||
val trigger = NarrationTrigger(kind = "test", instruction = "Narrate.")
|
||||
val pack = builder.buildNarrationContext(state, trigger, TokenBudget(limit = 5000))
|
||||
|
||||
val allContent = pack.layers.values.flatten().joinToString(" ") { it.content }
|
||||
assertTrue(
|
||||
!allContent.contains("secret conversation turn"),
|
||||
"buildNarrationContext must not include conversationHistory turns",
|
||||
)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
private fun buildFacade(
|
||||
eventStore: EventStore = MapBackedEventStore(),
|
||||
responseText: String = "narration",
|
||||
latencyMs: Long = 10L,
|
||||
tokensUsed: TokenUsage = TokenUsage(5, 5),
|
||||
l3MemoryStore: L3MemoryStore = InMemoryL3MemoryStore(),
|
||||
): DefaultRouterFacade = DefaultRouterFacade(
|
||||
routerRepository = object : RouterRepository {
|
||||
override suspend fun getRouterState(sessionId: SessionId): RouterState =
|
||||
RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = StageId("s1"),
|
||||
)
|
||||
},
|
||||
routerContextBuilder = DefaultRouterContextBuilder(RouterConfig()),
|
||||
inferenceRouter = mockInferenceRouter(responseText, latencyMs, tokensUsed),
|
||||
eventStore = eventStore,
|
||||
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
|
||||
embedder = NoopEmbedder(dimension = 8),
|
||||
l3MemoryStore = l3MemoryStore,
|
||||
)
|
||||
|
||||
private fun mockInferenceRouter(
|
||||
responseText: String,
|
||||
latencyMs: Long = 10L,
|
||||
tokensUsed: TokenUsage = TokenUsage(5, 5),
|
||||
): InferenceRouter = object : InferenceRouter {
|
||||
override suspend fun route(
|
||||
stageId: StageId,
|
||||
requiredCapabilities: Set<ModelCapability>,
|
||||
): InferenceProvider = object : InferenceProvider {
|
||||
override val id = ProviderId("mock")
|
||||
override val name = "Mock"
|
||||
override val tokenizer = MockTokenizer()
|
||||
override suspend fun infer(request: InferenceRequest): InferenceResponse = InferenceResponse(
|
||||
requestId = request.requestId,
|
||||
text = responseText,
|
||||
finishReason = FinishReason.Stop,
|
||||
tokensUsed = tokensUsed,
|
||||
latencyMs = latencyMs,
|
||||
)
|
||||
override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy
|
||||
override fun capabilities(): Set<CapabilityScore> = setOf(CapabilityScore(ModelCapability.General, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
private fun emptyContextPack(): ContextPack = ContextPack(
|
||||
id = ContextPackId("empty"),
|
||||
sessionId = SessionId("unknown"),
|
||||
stageId = StageId("none"),
|
||||
layers = emptyMap(),
|
||||
budgetUsed = 0,
|
||||
budgetLimit = 5000,
|
||||
)
|
||||
|
||||
private class MapBackedEventStore : EventStore {
|
||||
val appendedEvents = mutableListOf<NewEvent>()
|
||||
private val storedEvents: MutableMap<EventId, StoredEvent> = mutableMapOf()
|
||||
private var nextSequence = 1L
|
||||
|
||||
override suspend fun append(event: NewEvent): StoredEvent {
|
||||
appendedEvents.add(event)
|
||||
val stored = StoredEvent(
|
||||
metadata = event.metadata,
|
||||
sequence = nextSequence,
|
||||
sessionSequence = nextSequence++,
|
||||
payload = event.payload,
|
||||
)
|
||||
storedEvents[event.metadata.eventId] = stored
|
||||
return stored
|
||||
}
|
||||
|
||||
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> =
|
||||
events.map { append(it) }
|
||||
|
||||
override fun read(sessionId: SessionId): List<StoredEvent> =
|
||||
storedEvents.values.filter { it.metadata.sessionId == sessionId }.toList()
|
||||
|
||||
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
|
||||
read(sessionId).filter { it.sequence >= fromSequence }
|
||||
|
||||
override fun lastSequence(sessionId: SessionId): Long? =
|
||||
read(sessionId).maxOfOrNull { it.sequence }
|
||||
|
||||
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> =
|
||||
throw UnsupportedOperationException("subscribe not implemented for mock")
|
||||
|
||||
override fun allEvents(): Sequence<StoredEvent> = storedEvents.values.asSequence()
|
||||
|
||||
override fun allSessionIds(): Set<SessionId> = storedEvents.values.map { it.metadata.sessionId }.toSet()
|
||||
|
||||
override fun subscribeAll(): Flow<StoredEvent> = TODO("Not needed in this test context")
|
||||
|
||||
override suspend fun lastGlobalSequence(): Long = TODO("Not needed in this test context")
|
||||
}
|
||||
|
||||
private class TrackingL3MemoryStore : L3MemoryStore {
|
||||
val storedEntries = mutableListOf<L3MemoryEntry>()
|
||||
|
||||
override suspend fun store(entry: L3MemoryEntry) {
|
||||
storedEntries.add(entry)
|
||||
}
|
||||
|
||||
override suspend fun query(query: L3Query): List<com.correx.core.router.l3.L3Hit> = emptyList()
|
||||
|
||||
override suspend fun close() = Unit
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user