feat(router): enrich L2 decision points with steering and failure reason
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.
This commit is contained in:
@@ -214,7 +214,14 @@ class DefaultRouterContextBuilder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun buildL2Content(l2Entry: RouterL2Entry): String {
|
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(
|
private suspend fun buildContextEntry(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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.StageCompletedEvent
|
import com.correx.core.events.events.StageCompletedEvent
|
||||||
import com.correx.core.events.events.StageFailedEvent
|
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.StoredEvent
|
||||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||||
import com.correx.core.events.events.WorkflowFailedEvent
|
import com.correx.core.events.events.WorkflowFailedEvent
|
||||||
@@ -42,6 +43,7 @@ class DefaultRouterReducer : RouterReducer {
|
|||||||
is OrchestrationResumedEvent -> handleOrchestrationResumed(state)
|
is OrchestrationResumedEvent -> handleOrchestrationResumed(state)
|
||||||
is StageCompletedEvent -> handleStageCompleted(state, event)
|
is StageCompletedEvent -> handleStageCompleted(state, event)
|
||||||
is StageFailedEvent -> handleStageFailed(state, event)
|
is StageFailedEvent -> handleStageFailed(state, event)
|
||||||
|
is SteeringNoteAddedEvent -> handleSteeringNote(state, payload)
|
||||||
is ChatTurnEvent -> handleChatTurn(state, event)
|
is ChatTurnEvent -> handleChatTurn(state, event)
|
||||||
is L3MemoryRetrievedEvent -> state.copy(lastRetrievedMemory = payload.hits)
|
is L3MemoryRetrievedEvent -> state.copy(lastRetrievedMemory = payload.hits)
|
||||||
else -> state
|
else -> state
|
||||||
@@ -87,12 +89,14 @@ class DefaultRouterReducer : RouterReducer {
|
|||||||
val payload = event.payload as StageCompletedEvent
|
val payload = event.payload as StageCompletedEvent
|
||||||
val entry = RouterL2Entry(
|
val entry = RouterL2Entry(
|
||||||
stageId = payload.stageId,
|
stageId = payload.stageId,
|
||||||
summary = "Stage $payload.stageId completed",
|
summary = "Stage ${payload.stageId.value} completed",
|
||||||
outcome = StageOutcomeKind.SUCCESS,
|
outcome = StageOutcomeKind.SUCCESS,
|
||||||
timestamp = event.metadata.timestamp,
|
timestamp = event.metadata.timestamp,
|
||||||
|
steeringNotes = state.pendingSteeringNotes,
|
||||||
)
|
)
|
||||||
return state.copy(
|
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 payload = event.payload as StageFailedEvent
|
||||||
val entry = RouterL2Entry(
|
val entry = RouterL2Entry(
|
||||||
stageId = payload.stageId,
|
stageId = payload.stageId,
|
||||||
summary = "Stage $payload.stageId failed: ${payload.reason}",
|
summary = "Stage ${payload.stageId.value} failed",
|
||||||
outcome = StageOutcomeKind.FAILURE,
|
outcome = StageOutcomeKind.FAILURE,
|
||||||
timestamp = event.metadata.timestamp,
|
timestamp = event.metadata.timestamp,
|
||||||
|
reason = payload.reason,
|
||||||
|
steeringNotes = state.pendingSteeringNotes,
|
||||||
)
|
)
|
||||||
return state.copy(
|
return state.copy(
|
||||||
l2Memory = state.l2Memory + entry,
|
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 {
|
private fun handleChatTurn(state: RouterState, event: StoredEvent): RouterState {
|
||||||
val payload = event.payload as ChatTurnEvent
|
val payload = event.payload as ChatTurnEvent
|
||||||
val turnRole = when (payload.role) {
|
val turnRole = when (payload.role) {
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ data class RouterL2Entry(
|
|||||||
val summary: String,
|
val summary: String,
|
||||||
val outcome: StageOutcomeKind,
|
val outcome: StageOutcomeKind,
|
||||||
val timestamp: Instant,
|
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<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -52,4 +56,9 @@ data class RouterState(
|
|||||||
val l2Memory: List<RouterL2Entry> = emptyList(),
|
val l2Memory: List<RouterL2Entry> = emptyList(),
|
||||||
val conversationHistory: List<RouterTurn> = emptyList(),
|
val conversationHistory: List<RouterTurn> = emptyList(),
|
||||||
val lastRetrievedMemory: List<L3RetrievedHit> = emptyList(),
|
val lastRetrievedMemory: List<L3RetrievedHit> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Steering notes accumulated during the current stage, harvested into the
|
||||||
|
* stage's [RouterL2Entry] when it completes or fails, then cleared.
|
||||||
|
*/
|
||||||
|
val pendingSteeringNotes: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -147,13 +147,13 @@ class RouterProjectorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `apply(SteeringNoteAddedEvent) leaves state unchanged`() {
|
fun `apply(SteeringNoteAddedEvent) accumulates the note into pendingSteeringNotes`() {
|
||||||
val state = projector.initial()
|
val state = projector.initial()
|
||||||
val updated = projector.apply(
|
val updated = projector.apply(
|
||||||
state,
|
state,
|
||||||
stored(payload = SteeringNoteAddedEvent(sessionId, "do something different")),
|
stored(payload = SteeringNoteAddedEvent(sessionId, "do something different")),
|
||||||
)
|
)
|
||||||
assertEquals(state, updated)
|
assertEquals(listOf("do something different"), updated.pendingSteeringNotes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class RouterReducerTest {
|
|||||||
val entry = updated.l2Memory[0]
|
val entry = updated.l2Memory[0]
|
||||||
assertEquals(stageId, entry.stageId)
|
assertEquals(stageId, entry.stageId)
|
||||||
assertEquals(StageOutcomeKind.FAILURE, entry.outcome)
|
assertEquals(StageOutcomeKind.FAILURE, entry.outcome)
|
||||||
assertTrue(entry.summary.contains("timeout"))
|
assertEquals("timeout", entry.reason)
|
||||||
assertNull(updated.currentStageId)
|
assertNull(updated.currentStageId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,18 +175,18 @@ class RouterReducerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `SteeringNoteAddedEvent does not change state`() {
|
fun `SteeringNoteAddedEvent accumulates into pendingSteeringNotes without touching conversation`() {
|
||||||
val state = reducer.initial
|
val state = reducer.initial
|
||||||
val updated = reducer.reduce(
|
val updated = reducer.reduce(
|
||||||
state,
|
state,
|
||||||
stored(payload = SteeringNoteAddedEvent(sessionId, "do something different")),
|
stored(payload = SteeringNoteAddedEvent(sessionId, "do something different")),
|
||||||
)
|
)
|
||||||
assertEquals(state, updated)
|
assertEquals(listOf("do something different"), updated.pendingSteeringNotes)
|
||||||
assertTrue(updated.conversationHistory.isEmpty())
|
assertTrue(updated.conversationHistory.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `SteeringNoteAddedEvent on non-initial state does not change state`() {
|
fun `SteeringNoteAddedEvent preserves workflow status and current stage`() {
|
||||||
val initial = reducer.initial
|
val initial = reducer.initial
|
||||||
val started = reducer.reduce(
|
val started = reducer.reduce(
|
||||||
initial,
|
initial,
|
||||||
@@ -196,11 +196,22 @@ class RouterReducerTest {
|
|||||||
started,
|
started,
|
||||||
stored(payload = SteeringNoteAddedEvent(sessionId, "shift focus")),
|
stored(payload = SteeringNoteAddedEvent(sessionId, "shift focus")),
|
||||||
)
|
)
|
||||||
assertEquals(started, withNote)
|
assertEquals(listOf("shift focus"), withNote.pendingSteeringNotes)
|
||||||
assertEquals(WorkflowStatus.RUNNING, withNote.workflowStatus)
|
assertEquals(WorkflowStatus.RUNNING, withNote.workflowStatus)
|
||||||
assertEquals(stageId, withNote.currentStageId)
|
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
|
@Test
|
||||||
fun `unknown event type returns unchanged state`() {
|
fun `unknown event type returns unchanged state`() {
|
||||||
val state = reducer.initial
|
val state = reducer.initial
|
||||||
|
|||||||
Reference in New Issue
Block a user