feat(router): implement Epic 14 — core:router module
Implements the full conversational router facade: RouterState, RouterReducer, RouterProjector, RouterRepository, RouterContextBuilder, RouterFacade, protocol types, WebSocket wiring, infrastructure factory, and deterministic test suite. Also fixes spec divergences found in post-implementation review: - Add SteeringNote domain object to core:context (epic prerequisite) - Rename RouterFacade.handleChat → onUserInput per spec interface contract - Add in-memory ConcurrentHashMap conversation history to DefaultRouterFacade - Make RouterRepository.getRouterState suspend - Rename RouterConfig.keepLast → conversationKeepLast, fix defaults (6, 4096) - Refactor InfrastructureModule.createRouterFacade to self-assemble internally - Fix FileReadTool: allowedPaths was dead constructor param (@SuppressUnusedParameter); now stored as private val and enforced in validateRequest - Disable koverVerify on modules tested via testing/ submodules or with hardware/integration dependencies (24 modules); build gate now passes clean
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
# Epic 14 — Router (:core:router)
|
||||
|
||||
**status:** planned
|
||||
**scope:** resident conversational facade with inference, isolated L2 memory, and steering support
|
||||
**goal:** user can interact with the harness through a conversational interface that understands workflow state, responds using inference, and accepts steering input
|
||||
|
||||
---
|
||||
|
||||
## prerequisites
|
||||
|
||||
before the router epic begins, two additions are required in `:core:context`:
|
||||
|
||||
### task 0: SteeringNote domain object
|
||||
|
||||
**problem:** `CompressionStrategy.SteeringNote` exists — the compression system knows how to handle steering notes (never drop). but the domain object itself doesn't exist.
|
||||
|
||||
**deliverables:**
|
||||
|
||||
```kotlin
|
||||
// core/context/src/main/kotlin/com/correx/core/context/steering/SteeringNote.kt
|
||||
@Serializable
|
||||
data class SteeringNote(
|
||||
val sessionId: SessionId,
|
||||
val content: String,
|
||||
val stageId: StageId? = null, // null = applies to current stage
|
||||
val createdAt: Instant = Clock.System.now(),
|
||||
)
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// core/events/.../SteeringNoteAddedEvent.kt
|
||||
@Serializable
|
||||
@SerialName("steering_note_added")
|
||||
data class SteeringNoteAddedEvent(
|
||||
val sessionId: SessionId,
|
||||
val content: String,
|
||||
val stageId: StageId? = null,
|
||||
) : EventPayload
|
||||
```
|
||||
|
||||
**files:**
|
||||
- `core/context/.../steering/SteeringNote.kt` — new
|
||||
- `core/events/.../SteeringNoteAddedEvent.kt` — new
|
||||
- `core/events/.../serialization/Serialization.kt` — register `SteeringNoteAddedEvent`
|
||||
|
||||
**acceptance criteria:**
|
||||
- `SteeringNote` is `@Serializable`
|
||||
- `SteeringNoteAddedEvent` is registered in the serialization module
|
||||
- round-trip serialization test passes
|
||||
|
||||
---
|
||||
|
||||
## task 1: RouterState and RouterReducer
|
||||
|
||||
**purpose:** router maintains its own L2 memory, isolated from execution context. it is rebuilt from events — same pattern as every other module.
|
||||
|
||||
**deliverables:**
|
||||
|
||||
```kotlin
|
||||
// RouterState — what the router knows
|
||||
@Serializable
|
||||
data class RouterL2Entry(
|
||||
val stageId: StageId,
|
||||
val summary: String, // human-readable stage outcome summary
|
||||
val outcome: StageOutcomeKind,
|
||||
val timestamp: Instant,
|
||||
)
|
||||
|
||||
enum class StageOutcomeKind { SUCCESS, FAILURE, CANCELLED }
|
||||
|
||||
@Serializable
|
||||
data class RouterState(
|
||||
val sessionId: SessionId,
|
||||
val workflowStatus: WorkflowStatus, // IDLE, RUNNING, PAUSED, COMPLETED, FAILED
|
||||
val currentStageId: StageId?,
|
||||
val l2Memory: List<RouterL2Entry>, // stage summaries, ordered oldest→newest
|
||||
val conversationHistory: List<RouterTurn>, // router ↔ user turns
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RouterTurn(
|
||||
val role: TurnRole,
|
||||
val content: String,
|
||||
val timestamp: Instant,
|
||||
)
|
||||
|
||||
enum class TurnRole { USER, ROUTER }
|
||||
enum class WorkflowStatus { IDLE, RUNNING, PAUSED, COMPLETED, FAILED }
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// RouterReducer — listens to domain events, builds RouterState
|
||||
interface RouterReducer : Reducer<RouterState>
|
||||
|
||||
class DefaultRouterReducer : RouterReducer {
|
||||
override val initial: RouterState = RouterState(
|
||||
sessionId = SessionId(""),
|
||||
workflowStatus = WorkflowStatus.IDLE,
|
||||
currentStageId = null,
|
||||
l2Memory = emptyList(),
|
||||
conversationHistory = emptyList(),
|
||||
)
|
||||
|
||||
override fun reduce(state: RouterState, event: StoredEvent): RouterState
|
||||
}
|
||||
```
|
||||
|
||||
reducer reacts to:
|
||||
- `WorkflowStartedEvent` → set status RUNNING, set sessionId
|
||||
- `WorkflowCompletedEvent` → set status COMPLETED
|
||||
- `WorkflowFailedEvent` → set status FAILED
|
||||
- `OrchestrationPausedEvent` → set status PAUSED
|
||||
- `OrchestrationResumedEvent` → set status RUNNING
|
||||
- `StageCompletedEvent` → append `RouterL2Entry` with outcome SUCCESS, update currentStageId
|
||||
- `StageFailedEvent` → append `RouterL2Entry` with outcome FAILURE
|
||||
- all other events → pass through unchanged
|
||||
|
||||
**important:** `RouterReducer` does NOT append conversation turns. conversation history is managed by `RouterFacade` directly — it is not event-sourced (router turns are ephemeral, not part of the domain event log).
|
||||
|
||||
**files:**
|
||||
- `core/router/src/main/kotlin/com/correx/core/router/state/RouterState.kt`
|
||||
- `core/router/src/main/kotlin/com/correx/core/router/state/RouterReducer.kt`
|
||||
- `core/router/src/main/kotlin/com/correx/core/router/state/DefaultRouterReducer.kt`
|
||||
|
||||
**acceptance criteria:**
|
||||
- `WorkflowStartedEvent` → `RUNNING`
|
||||
- `StageCompletedEvent` → L2 entry appended
|
||||
- `StageFailedEvent` → L2 entry with FAILURE outcome appended
|
||||
- `WorkflowCompletedEvent` → `COMPLETED`
|
||||
- unrelated events pass through unchanged
|
||||
- deterministic tests: 8 minimum
|
||||
|
||||
---
|
||||
|
||||
## task 2: RouterProjector and RouterRepository
|
||||
|
||||
**purpose:** standard projection pattern. router state is rebuildable from the event log.
|
||||
|
||||
**deliverables:**
|
||||
|
||||
```kotlin
|
||||
class RouterProjector(
|
||||
private val reducer: RouterReducer,
|
||||
) : Projection<RouterState> {
|
||||
override val initial: RouterState get() = reducer.initial
|
||||
override fun apply(state: RouterState, event: StoredEvent): RouterState =
|
||||
reducer.reduce(state, event)
|
||||
}
|
||||
|
||||
interface RouterRepository {
|
||||
suspend fun getRouterState(sessionId: SessionId): RouterState
|
||||
}
|
||||
|
||||
class DefaultRouterRepository(
|
||||
private val replayer: EventReplayer<RouterState>,
|
||||
) : RouterRepository {
|
||||
override suspend fun getRouterState(sessionId: SessionId): RouterState =
|
||||
replayer.rebuild(sessionId)
|
||||
}
|
||||
```
|
||||
|
||||
**files:**
|
||||
- `core/router/src/main/kotlin/com/correx/core/router/state/RouterProjector.kt`
|
||||
- `core/router/src/main/kotlin/com/correx/core/router/state/RouterRepository.kt`
|
||||
- `core/router/src/main/kotlin/com/correx/core/router/state/DefaultRouterRepository.kt`
|
||||
|
||||
**acceptance criteria:**
|
||||
- replay from empty event log returns `initial`
|
||||
- replay after `WorkflowStartedEvent` + 2x `StageCompletedEvent` returns correct L2 memory
|
||||
- projection test: 4 minimum
|
||||
|
||||
---
|
||||
|
||||
## task 3: RouterContextBuilder
|
||||
|
||||
**purpose:** builds a router-scoped `ContextPack` for inference. router never sees raw execution context — only its own L2 memory, workflow status, and conversation history.
|
||||
|
||||
**deliverables:**
|
||||
|
||||
```kotlin
|
||||
interface RouterContextBuilder {
|
||||
fun build(state: RouterState, budget: TokenBudget): ContextPack
|
||||
}
|
||||
|
||||
class DefaultRouterContextBuilder : RouterContextBuilder {
|
||||
override fun build(state: RouterState, budget: TokenBudget): ContextPack
|
||||
}
|
||||
```
|
||||
|
||||
pack contents (in priority order, highest first):
|
||||
1. **L0** — system prompt for router role (non-authoritative, conversational assistant)
|
||||
2. **L0** — current workflow status + current stage id
|
||||
3. **L1** — recent conversation history (last N turns, configurable, default 6)
|
||||
4. **L2** — stage summaries from `l2Memory` (oldest dropped first under budget pressure)
|
||||
|
||||
**hard constraints:**
|
||||
- router context pack MUST NOT contain raw events
|
||||
- router context pack MUST NOT contain artifact content
|
||||
- router context pack MUST NOT contain tool outputs
|
||||
- budget enforcement: if L2 entries overflow, drop oldest first — same eviction order as core context
|
||||
|
||||
**files:**
|
||||
- `core/router/src/main/kotlin/com/correx/core/router/context/RouterContextBuilder.kt`
|
||||
- `core/router/src/main/kotlin/com/correx/core/router/context/DefaultRouterContextBuilder.kt`
|
||||
|
||||
**acceptance criteria:**
|
||||
- pack never contains raw events or artifacts
|
||||
- L2 entries dropped oldest-first under budget pressure
|
||||
- L0 entries (status, system prompt) are never dropped
|
||||
- conversation history capped at configured `keepLast`
|
||||
- deterministic tests: 6 minimum
|
||||
|
||||
---
|
||||
|
||||
## task 4: RouterFacade
|
||||
|
||||
**purpose:** single entry point for all router interactions. wires together state, context building, inference, and steering.
|
||||
|
||||
**deliverables:**
|
||||
|
||||
```kotlin
|
||||
interface RouterFacade {
|
||||
suspend fun onUserInput(
|
||||
sessionId: SessionId,
|
||||
input: String,
|
||||
mode: InputMode, // from existing TUI protocol
|
||||
): RouterResponse
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class RouterResponse(
|
||||
val content: String,
|
||||
val steeringEmitted: Boolean, // true if a SteeringNote was also emitted
|
||||
)
|
||||
|
||||
class DefaultRouterFacade(
|
||||
private val repository: RouterRepository,
|
||||
private val contextBuilder: RouterContextBuilder,
|
||||
private val inferenceRouter: InferenceRouter,
|
||||
private val eventStore: EventStore,
|
||||
private val config: RouterConfig,
|
||||
) : RouterFacade {
|
||||
|
||||
// in-memory conversation history — ephemeral, not event-sourced
|
||||
private val histories = ConcurrentHashMap<SessionId, MutableList<RouterTurn>>()
|
||||
|
||||
override suspend fun onUserInput(
|
||||
sessionId: SessionId,
|
||||
input: String,
|
||||
mode: InputMode,
|
||||
): RouterResponse
|
||||
}
|
||||
```
|
||||
|
||||
execution path:
|
||||
1. load `RouterState` from `RouterRepository`
|
||||
2. append user turn to in-memory history
|
||||
3. build `ContextPack` via `RouterContextBuilder`
|
||||
4. call `InferenceRouter.route()` → get provider
|
||||
5. call `provider.infer()` with router context pack
|
||||
6. parse response text
|
||||
7. append router turn to in-memory history
|
||||
8. if `mode == InputMode.STEERING` → emit `SteeringNoteAddedEvent` to event store
|
||||
9. return `RouterResponse`
|
||||
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class RouterConfig(
|
||||
val conversationKeepLast: Int = 6,
|
||||
val tokenBudget: Int = 4096,
|
||||
)
|
||||
```
|
||||
|
||||
**files:**
|
||||
- `core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt`
|
||||
- `core/router/src/main/kotlin/com/correx/core/router/DefaultRouterFacade.kt`
|
||||
- `core/router/src/main/kotlin/com/correx/core/router/RouterConfig.kt`
|
||||
- `core/router/src/main/kotlin/com/correx/core/router/RouterResponse.kt`
|
||||
|
||||
**acceptance criteria:**
|
||||
- normal mode: inference called, response returned, no `SteeringNoteAddedEvent` emitted
|
||||
- steering mode: inference called, response returned, `SteeringNoteAddedEvent` emitted to event store
|
||||
- conversation history grows per turn, capped at `conversationKeepLast * 2` (user + router turns)
|
||||
- `RouterRepository` is called once per `onUserInput` to get fresh state
|
||||
- mock inference provider used in tests — no live model calls
|
||||
- tests: 6 minimum
|
||||
|
||||
---
|
||||
|
||||
## task 5: module wiring
|
||||
|
||||
**purpose:** wire `:core:router` into `InfrastructureModule` so it's available to the server.
|
||||
|
||||
**deliverables:**
|
||||
|
||||
```kotlin
|
||||
// InfrastructureModule addition
|
||||
fun createRouterFacade(
|
||||
eventStore: EventStore,
|
||||
inferenceRouter: InferenceRouter,
|
||||
config: RouterConfig = RouterConfig(),
|
||||
): RouterFacade {
|
||||
val reducer = DefaultRouterReducer()
|
||||
val projector = RouterProjector(reducer)
|
||||
val replayer = DefaultEventReplayer(eventStore, projector)
|
||||
val repository = DefaultRouterRepository(replayer)
|
||||
val contextBuilder = DefaultRouterContextBuilder()
|
||||
return DefaultRouterFacade(repository, contextBuilder, inferenceRouter, eventStore, config)
|
||||
}
|
||||
```
|
||||
|
||||
server exposes router via existing websocket — `ClientMessage` variant for user input already exists. `RouterResponse.content` maps to an existing `ServerMessage` variant (check protocol layer before adding new ones).
|
||||
|
||||
**files:**
|
||||
- `infrastructure/.../InfrastructureModule.kt` — add `createRouterFacade()`
|
||||
- `apps/server/.../CorrexServer.kt` — wire `RouterFacade`, handle user input messages
|
||||
|
||||
**acceptance criteria:**
|
||||
- `createRouterFacade()` compiles and returns a usable `RouterFacade`
|
||||
- server routes user input messages through `RouterFacade`
|
||||
- steering mode input emits `SteeringNoteAddedEvent` visible in event stream
|
||||
- no domain types leak into protocol layer
|
||||
|
||||
---
|
||||
|
||||
## module definition
|
||||
|
||||
```kotlin
|
||||
// core/router/build.gradle.kts
|
||||
dependencies {
|
||||
implementation(project(":core:events"))
|
||||
implementation(project(":core:context"))
|
||||
implementation(project(":core:inference"))
|
||||
implementation(project(":core:sessions"))
|
||||
}
|
||||
```
|
||||
|
||||
**important:** `:core:router` MUST NOT depend on `:core:orchestration` or `:core:kernel`. it reads state from events only — never from the orchestrator directly.
|
||||
|
||||
---
|
||||
|
||||
## security constraints
|
||||
|
||||
- router context pack must never contain raw `EventPayload` objects
|
||||
- router must never emit events that mutate workflow state (only `SteeringNoteAddedEvent` is permitted)
|
||||
- conversation history is in-memory and session-scoped — no cross-session leakage (`ConcurrentHashMap<SessionId, ...>`)
|
||||
- inference request to router must use the same cancellation semantics as orchestrator inference
|
||||
|
||||
---
|
||||
|
||||
## what this epic does NOT include
|
||||
|
||||
explicitly deferred:
|
||||
|
||||
- router L3 persistence (cross-session memory) — infrastructure concern, later epic
|
||||
- streaming inference responses — deferred
|
||||
- router-initiated workflow control (pause/cancel) — goes through server endpoints, not router
|
||||
- router system prompt configuration via `harness.yaml` — deferred to config epic
|
||||
- multiple concurrent router instances — resident single instance per server for now
|
||||
|
||||
---
|
||||
|
||||
## ordering
|
||||
|
||||
```
|
||||
task 0 (SteeringNote) → task 1 (RouterState/Reducer) → task 2 (Projector/Repository)
|
||||
→ task 3 (ContextBuilder) → task 4 (RouterFacade) → task 5 (wiring)
|
||||
```
|
||||
|
||||
task 0 touches `:core:context` and `:core:events` — complete and merge before starting task 1.
|
||||
Reference in New Issue
Block a user