fix: complete P4 audit findings — steering wiring, generation config

P4-1: Wire steering to actually work (advisory-only):
  - RouterFacade emits SteeringNoteAddedEvent via event store in STEERING mode
  - SessionOrchestrator reads steering notes from event store and includes them
    as ContextEntry objects (sourceType=steeringNote) in stage context packs
  - Delete dead SteeringNote data class; single vocabulary = SteeringNoteAddedEvent
  - Add optional validateSteering callback to DefaultRouterFacade for future
    ValidationPipeline wiring (avoids cross-core dependency)
  - Update RouterFacadeTest to assert event emission

P4-2: Router FACE — move hardcoded generation config:
  - Add GenerationConfig field to RouterConfig with same defaults
  - RouterFacade uses config.generationConfig instead of inline literals
  - (Remaining P4-2 items: conversation persistence and coordination
    semantics are design decisions requiring ADRs, deferred)
This commit is contained in:
2026-05-29 01:19:18 +04:00
parent f7f237e29f
commit 3981d8443d
5 changed files with 69 additions and 37 deletions
@@ -1,15 +0,0 @@
package com.correx.core.context.steering
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@Serializable
data class SteeringNote(
val sessionId: SessionId,
val content: String,
val stageId: StageId? = null,
val createdAt: Instant = Clock.System.now(),
)
@@ -35,6 +35,7 @@ import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.RiskAssessedEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.ToolRequest
@@ -237,12 +238,13 @@ abstract class SessionOrchestrator(
?: ResponseFormat.Text
val schemaEntries = buildSchemaEntries(responseFormat, stageId)
val steeringEntries = buildSteeringNoteEntries(sessionId)
val contextPack = contextPackBuilder.build(
id = ContextPackId(UUID.randomUUID().toString()),
sessionId = sessionId,
stageId = stageId,
entries = systemPrompt + schemaEntries + promptEntries,
entries = systemPrompt + schemaEntries + promptEntries + steeringEntries,
budget = TokenBudget(limit = stageConfig.tokenBudget),
)
@@ -544,6 +546,23 @@ abstract class SessionOrchestrator(
)
}
private suspend fun buildSteeringNoteEntries(sessionId: SessionId): List<ContextEntry> {
val events = eventStore.read(sessionId)
return events.mapNotNull { event ->
(event.payload as? SteeringNoteAddedEvent)?.let { steering ->
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
content = steering.content,
sourceType = "steeringNote",
sourceId = steering.stageId?.value ?: sessionId.value,
tokenEstimate = estimateTokens(steering.content),
role = EntryRole.SYSTEM,
)
}
}
}
private suspend fun emitToolArtifacts(
sessionId: SessionId,
stageId: StageId,
@@ -1,10 +1,15 @@
package com.correx.core.router
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.CausationId
import com.correx.core.events.types.CorrelationId
import com.correx.core.events.types.EventId
import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.GenerationConfig
import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.ModelCapability
@@ -31,6 +36,7 @@ class DefaultRouterFacade(
private val inferenceRouter: InferenceRouter,
private val eventStore: EventStore,
private val config: RouterConfig,
private val validateSteering: (suspend (String) -> String?)? = null,
) : RouterFacade {
private val histories = ConcurrentHashMap<SessionId, MutableList<RouterTurn>>()
@@ -55,11 +61,7 @@ class DefaultRouterFacade(
sessionId = sessionId,
stageId = effectiveStageId,
contextPack = contextPack,
generationConfig = GenerationConfig(
temperature = 0.7,
topP = 0.9,
maxTokens = 512,
),
generationConfig = config.generationConfig,
responseFormat = ResponseFormat.Text,
)
val inferenceResponse = provider.infer(inferenceRequest)
@@ -67,11 +69,28 @@ class DefaultRouterFacade(
history.add(RouterTurn(role = TurnRole.ROUTER, content = content, timestamp = Clock.System.now()))
// NOTE: SteeringNoteAddedEvent emission is deferred to P4-1, which will wire
// proper validation (via ValidationPipeline) and consumption by the context builder.
// The current SteeringNoteAddedEvent had wrong content (user raw input instead of
// LLM-processed steering text) and was emitted without validation (P0-6).
// The steeringEmitted flag below still informs the TUI that steering mode was used.
if (mode == ChatMode.STEERING) {
val validationError = validateSteering?.invoke(content)
if (validationError == null) {
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = SteeringNoteAddedEvent(
sessionId = sessionId,
content = content,
stageId = effectiveStageId.takeIf { it != StageId.NONE },
),
),
)
}
}
return RouterResponse(content = content, steeringEmitted = (mode == ChatMode.STEERING))
}
@@ -80,4 +99,4 @@ class DefaultRouterFacade(
enum class ChatMode {
CHAT,
STEERING,
}
}
@@ -1,10 +1,16 @@
package com.correx.core.router.model
import com.correx.core.context.model.TokenBudget
import com.correx.core.inference.GenerationConfig
import kotlinx.serialization.Serializable
@Serializable
data class RouterConfig(
val conversationKeepLast: Int = 6,
val tokenBudget: TokenBudget = TokenBudget(limit = 4096),
val generationConfig: GenerationConfig = GenerationConfig(
temperature = 0.7,
topP = 0.9,
maxTokens = 512,
),
)
@@ -81,17 +81,18 @@ class RouterFacadeTest {
}
@Test
fun `STEERING mode does not append events to store`(): Unit = runBlocking {
// SteeringNoteAddedEvent emission is deferred to P4-1 (see P0-6).
// The steeringEmitted flag still informs the TUI that steering mode was used.
fun `STEERING mode appends SteeringNoteAddedEvent to store`(): Unit = runBlocking {
val mockStore = mockEventStore()
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.STEERING)
facade.onUserInput(sessionId = SessionId("session-xyz"), input = "steer this way")
assertTrue(mockStore.appendedEvents.isEmpty())
assertEquals(1, mockStore.appendedEvents.size)
val payload = mockStore.appendedEvents.single().payload
assertTrue(payload is com.correx.core.events.events.SteeringNoteAddedEvent)
assertEquals("inference response", (payload as com.correx.core.events.events.SteeringNoteAddedEvent).content)
}
@Test
fun `STEERING mode does not append events regardless of state stageId`(): Unit = runBlocking {
fun `STEERING mode appends SteeringNoteAddedEvent with current stageId`(): Unit = runBlocking {
val mockStore = mockEventStore()
val stageId = StageId("stage-A")
val facade = DefaultRouterFacade(
@@ -106,12 +107,15 @@ class RouterFacadeTest {
routerContextBuilder = object : RouterContextBuilder {
override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack()
},
inferenceRouter = mockInferenceRouter("response"),
inferenceRouter = mockInferenceRouter("steering response"),
eventStore = mockStore,
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
)
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!", mode = ChatMode.STEERING)
assertTrue(mockStore.appendedEvents.isEmpty())
assertEquals(1, mockStore.appendedEvents.size)
val payload = mockStore.appendedEvents.single().payload
assertTrue(payload is com.correx.core.events.events.SteeringNoteAddedEvent)
assertEquals("steering response", (payload as com.correx.core.events.events.SteeringNoteAddedEvent).content)
}
// --------------------------------------------------------------------------
@@ -449,8 +453,7 @@ class RouterFacadeTest {
assertNotNull(response)
assertEquals("inference response", response.content)
assertTrue(response.steeringEmitted)
// Steering events are deferred to P4-1; no events appended yet.
assertTrue(mockStore.appendedEvents.isEmpty())
assertEquals(1, mockStore.appendedEvents.size)
}
// --------------------------------------------------------------------------