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.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.RiskAssessedEvent 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.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.ToolRequest import com.correx.core.events.events.ToolRequest
@@ -237,12 +238,13 @@ abstract class SessionOrchestrator(
?: ResponseFormat.Text ?: ResponseFormat.Text
val schemaEntries = buildSchemaEntries(responseFormat, stageId) val schemaEntries = buildSchemaEntries(responseFormat, stageId)
val steeringEntries = buildSteeringNoteEntries(sessionId)
val contextPack = contextPackBuilder.build( val contextPack = contextPackBuilder.build(
id = ContextPackId(UUID.randomUUID().toString()), id = ContextPackId(UUID.randomUUID().toString()),
sessionId = sessionId, sessionId = sessionId,
stageId = stageId, stageId = stageId,
entries = systemPrompt + schemaEntries + promptEntries, entries = systemPrompt + schemaEntries + promptEntries + steeringEntries,
budget = TokenBudget(limit = stageConfig.tokenBudget), 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( private suspend fun emitToolArtifacts(
sessionId: SessionId, sessionId: SessionId,
stageId: StageId, stageId: StageId,
@@ -1,10 +1,15 @@
package com.correx.core.router 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.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.InferenceRequestId
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import com.correx.core.inference.GenerationConfig
import com.correx.core.inference.InferenceRequest import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.InferenceRouter import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.ModelCapability import com.correx.core.inference.ModelCapability
@@ -31,6 +36,7 @@ class DefaultRouterFacade(
private val inferenceRouter: InferenceRouter, private val inferenceRouter: InferenceRouter,
private val eventStore: EventStore, private val eventStore: EventStore,
private val config: RouterConfig, private val config: RouterConfig,
private val validateSteering: (suspend (String) -> String?)? = null,
) : RouterFacade { ) : RouterFacade {
private val histories = ConcurrentHashMap<SessionId, MutableList<RouterTurn>>() private val histories = ConcurrentHashMap<SessionId, MutableList<RouterTurn>>()
@@ -55,11 +61,7 @@ class DefaultRouterFacade(
sessionId = sessionId, sessionId = sessionId,
stageId = effectiveStageId, stageId = effectiveStageId,
contextPack = contextPack, contextPack = contextPack,
generationConfig = GenerationConfig( generationConfig = config.generationConfig,
temperature = 0.7,
topP = 0.9,
maxTokens = 512,
),
responseFormat = ResponseFormat.Text, responseFormat = ResponseFormat.Text,
) )
val inferenceResponse = provider.infer(inferenceRequest) val inferenceResponse = provider.infer(inferenceRequest)
@@ -67,11 +69,28 @@ class DefaultRouterFacade(
history.add(RouterTurn(role = TurnRole.ROUTER, content = content, timestamp = Clock.System.now())) history.add(RouterTurn(role = TurnRole.ROUTER, content = content, timestamp = Clock.System.now()))
// NOTE: SteeringNoteAddedEvent emission is deferred to P4-1, which will wire if (mode == ChatMode.STEERING) {
// proper validation (via ValidationPipeline) and consumption by the context builder. val validationError = validateSteering?.invoke(content)
// The current SteeringNoteAddedEvent had wrong content (user raw input instead of if (validationError == null) {
// LLM-processed steering text) and was emitted without validation (P0-6). eventStore.append(
// The steeringEmitted flag below still informs the TUI that steering mode was used. 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)) return RouterResponse(content = content, steeringEmitted = (mode == ChatMode.STEERING))
} }
@@ -80,4 +99,4 @@ class DefaultRouterFacade(
enum class ChatMode { enum class ChatMode {
CHAT, CHAT,
STEERING, STEERING,
} }
@@ -1,10 +1,16 @@
package com.correx.core.router.model package com.correx.core.router.model
import com.correx.core.context.model.TokenBudget import com.correx.core.context.model.TokenBudget
import com.correx.core.inference.GenerationConfig
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class RouterConfig( data class RouterConfig(
val conversationKeepLast: Int = 6, val conversationKeepLast: Int = 6,
val tokenBudget: TokenBudget = TokenBudget(limit = 4096), 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 @Test
fun `STEERING mode does not append events to store`(): Unit = runBlocking { fun `STEERING mode appends SteeringNoteAddedEvent 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.
val mockStore = mockEventStore() val mockStore = mockEventStore()
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.STEERING) val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.STEERING)
facade.onUserInput(sessionId = SessionId("session-xyz"), input = "steer this way") 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 @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 mockStore = mockEventStore()
val stageId = StageId("stage-A") val stageId = StageId("stage-A")
val facade = DefaultRouterFacade( val facade = DefaultRouterFacade(
@@ -106,12 +107,15 @@ class RouterFacadeTest {
routerContextBuilder = object : RouterContextBuilder { routerContextBuilder = object : RouterContextBuilder {
override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack() override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack()
}, },
inferenceRouter = mockInferenceRouter("response"), inferenceRouter = mockInferenceRouter("steering response"),
eventStore = mockStore, eventStore = mockStore,
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
) )
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!", mode = ChatMode.STEERING) 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) assertNotNull(response)
assertEquals("inference response", response.content) assertEquals("inference response", response.content)
assertTrue(response.steeringEmitted) assertTrue(response.steeringEmitted)
// Steering events are deferred to P4-1; no events appended yet. assertEquals(1, mockStore.appendedEvents.size)
assertTrue(mockStore.appendedEvents.isEmpty())
} }
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------