From 1dc7eae8a176b41932d0a4ae2358703c12c14e19 Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 2 Jun 2026 13:58:41 +0400 Subject: [PATCH] feat(router): enrich L2 decision points with steering and failure reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix broken stage-summary interpolation ($payload.stageId rendered the event's toString()). RouterL2Entry gains reason + steeringNotes; the reducer folds SteeringNoteAddedEvent into pendingSteeringNotes and harvests them into the completing stage's L2 entry, then clears (adr-0003 §4, pure field extraction, zero inference). RouterContextBuilder renders summary + reason + steering. --- .../core/router/RouterContextBuilder.kt | 9 +++++++- .../com/correx/core/router/RouterReducer.kt | 21 +++++++++++++++---- .../correx/core/router/model/RouterState.kt | 9 ++++++++ .../src/test/kotlin/RouterProjectorTest.kt | 4 ++-- .../src/test/kotlin/RouterReducerTest.kt | 21 ++++++++++++++----- 5 files changed, 52 insertions(+), 12 deletions(-) diff --git a/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt b/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt index 4c483720..d09a30e6 100644 --- a/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt +++ b/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt @@ -214,7 +214,14 @@ class DefaultRouterContextBuilder( } private fun buildL2Content(l2Entry: RouterL2Entry): String { - return "Stage ${l2Entry.stageId.value} (${l2Entry.outcome}): ${l2Entry.summary}" + val base = "Stage ${l2Entry.stageId.value} (${l2Entry.outcome}): ${l2Entry.summary}" + val reason = l2Entry.reason?.let { " — reason: $it" }.orEmpty() + val steering = if (l2Entry.steeringNotes.isNotEmpty()) { + " | steering: " + l2Entry.steeringNotes.joinToString("; ") + } else { + "" + } + return base + reason + steering } private suspend fun buildContextEntry( diff --git a/core/router/src/main/kotlin/com/correx/core/router/RouterReducer.kt b/core/router/src/main/kotlin/com/correx/core/router/RouterReducer.kt index fae57da0..ec6282b9 100644 --- a/core/router/src/main/kotlin/com/correx/core/router/RouterReducer.kt +++ b/core/router/src/main/kotlin/com/correx/core/router/RouterReducer.kt @@ -6,6 +6,7 @@ import com.correx.core.events.events.OrchestrationPausedEvent import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.StageCompletedEvent import com.correx.core.events.events.StageFailedEvent +import com.correx.core.events.events.SteeringNoteAddedEvent import com.correx.core.events.events.StoredEvent import com.correx.core.events.events.WorkflowCompletedEvent import com.correx.core.events.events.WorkflowFailedEvent @@ -42,6 +43,7 @@ class DefaultRouterReducer : RouterReducer { is OrchestrationResumedEvent -> handleOrchestrationResumed(state) is StageCompletedEvent -> handleStageCompleted(state, event) is StageFailedEvent -> handleStageFailed(state, event) + is SteeringNoteAddedEvent -> handleSteeringNote(state, payload) is ChatTurnEvent -> handleChatTurn(state, event) is L3MemoryRetrievedEvent -> state.copy(lastRetrievedMemory = payload.hits) else -> state @@ -87,12 +89,14 @@ class DefaultRouterReducer : RouterReducer { val payload = event.payload as StageCompletedEvent val entry = RouterL2Entry( stageId = payload.stageId, - summary = "Stage $payload.stageId completed", + summary = "Stage ${payload.stageId.value} completed", outcome = StageOutcomeKind.SUCCESS, timestamp = event.metadata.timestamp, + steeringNotes = state.pendingSteeringNotes, ) return state.copy( - l2Memory = state.l2Memory + entry + l2Memory = state.l2Memory + entry, + pendingSteeringNotes = emptyList(), ) } @@ -100,16 +104,25 @@ class DefaultRouterReducer : RouterReducer { val payload = event.payload as StageFailedEvent val entry = RouterL2Entry( stageId = payload.stageId, - summary = "Stage $payload.stageId failed: ${payload.reason}", + summary = "Stage ${payload.stageId.value} failed", outcome = StageOutcomeKind.FAILURE, timestamp = event.metadata.timestamp, + reason = payload.reason, + steeringNotes = state.pendingSteeringNotes, ) return state.copy( l2Memory = state.l2Memory + entry, - currentStageId = null + currentStageId = null, + pendingSteeringNotes = emptyList(), ) } + // adr-0003 §4: harvest user steering into the current stage's decision point. + // Accumulate here; handleStageCompleted/Failed attach and clear. Pure field + // extraction from the event stream — no inference. + private fun handleSteeringNote(state: RouterState, payload: SteeringNoteAddedEvent): RouterState = + state.copy(pendingSteeringNotes = state.pendingSteeringNotes + payload.content) + private fun handleChatTurn(state: RouterState, event: StoredEvent): RouterState { val payload = event.payload as ChatTurnEvent val turnRole = when (payload.role) { diff --git a/core/router/src/main/kotlin/com/correx/core/router/model/RouterState.kt b/core/router/src/main/kotlin/com/correx/core/router/model/RouterState.kt index 3512142c..39382ea1 100644 --- a/core/router/src/main/kotlin/com/correx/core/router/model/RouterState.kt +++ b/core/router/src/main/kotlin/com/correx/core/router/model/RouterState.kt @@ -34,6 +34,10 @@ data class RouterL2Entry( val summary: String, val outcome: StageOutcomeKind, val timestamp: Instant, + /** Failure reason, when [outcome] is FAILURE. */ + val reason: String? = null, + /** User steering notes issued during this stage (adr-0003 §4 decision-point harvest). */ + val steeringNotes: List = emptyList(), ) @Serializable @@ -52,4 +56,9 @@ data class RouterState( val l2Memory: List = emptyList(), val conversationHistory: List = emptyList(), val lastRetrievedMemory: List = emptyList(), + /** + * Steering notes accumulated during the current stage, harvested into the + * stage's [RouterL2Entry] when it completes or fails, then cleared. + */ + val pendingSteeringNotes: List = emptyList(), ) diff --git a/testing/deterministic/src/test/kotlin/RouterProjectorTest.kt b/testing/deterministic/src/test/kotlin/RouterProjectorTest.kt index bc922b64..1a91020f 100644 --- a/testing/deterministic/src/test/kotlin/RouterProjectorTest.kt +++ b/testing/deterministic/src/test/kotlin/RouterProjectorTest.kt @@ -147,13 +147,13 @@ class RouterProjectorTest { } @Test - fun `apply(SteeringNoteAddedEvent) leaves state unchanged`() { + fun `apply(SteeringNoteAddedEvent) accumulates the note into pendingSteeringNotes`() { val state = projector.initial() val updated = projector.apply( state, stored(payload = SteeringNoteAddedEvent(sessionId, "do something different")), ) - assertEquals(state, updated) + assertEquals(listOf("do something different"), updated.pendingSteeringNotes) } @Test diff --git a/testing/deterministic/src/test/kotlin/RouterReducerTest.kt b/testing/deterministic/src/test/kotlin/RouterReducerTest.kt index dde79b61..2e2287c2 100644 --- a/testing/deterministic/src/test/kotlin/RouterReducerTest.kt +++ b/testing/deterministic/src/test/kotlin/RouterReducerTest.kt @@ -155,7 +155,7 @@ class RouterReducerTest { val entry = updated.l2Memory[0] assertEquals(stageId, entry.stageId) assertEquals(StageOutcomeKind.FAILURE, entry.outcome) - assertTrue(entry.summary.contains("timeout")) + assertEquals("timeout", entry.reason) assertNull(updated.currentStageId) } @@ -175,18 +175,18 @@ class RouterReducerTest { } @Test - fun `SteeringNoteAddedEvent does not change state`() { + fun `SteeringNoteAddedEvent accumulates into pendingSteeringNotes without touching conversation`() { val state = reducer.initial val updated = reducer.reduce( state, stored(payload = SteeringNoteAddedEvent(sessionId, "do something different")), ) - assertEquals(state, updated) + assertEquals(listOf("do something different"), updated.pendingSteeringNotes) assertTrue(updated.conversationHistory.isEmpty()) } @Test - fun `SteeringNoteAddedEvent on non-initial state does not change state`() { + fun `SteeringNoteAddedEvent preserves workflow status and current stage`() { val initial = reducer.initial val started = reducer.reduce( initial, @@ -196,11 +196,22 @@ class RouterReducerTest { started, stored(payload = SteeringNoteAddedEvent(sessionId, "shift focus")), ) - assertEquals(started, withNote) + assertEquals(listOf("shift focus"), withNote.pendingSteeringNotes) assertEquals(WorkflowStatus.RUNNING, withNote.workflowStatus) assertEquals(stageId, withNote.currentStageId) } + @Test + fun `steering notes are harvested into the completing stage's L2 entry and then cleared`() { + val initial = reducer.initial + val started = reducer.reduce(initial, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId))) + val steered = reducer.reduce(started, stored(payload = SteeringNoteAddedEvent(sessionId, "call it the runtime"))) + val completed = reducer.reduce(steered, stored(payload = StageCompletedEvent(sessionId, stageId, StageId("t1")))) + + assertEquals(listOf("call it the runtime"), completed.l2Memory[0].steeringNotes) + assertTrue(completed.pendingSteeringNotes.isEmpty()) + } + @Test fun `unknown event type returns unchanged state`() { val state = reducer.initial