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:
2026-06-02 13:58:41 +04:00
parent d1c6774d05
commit 1dc7eae8a1
5 changed files with 52 additions and 12 deletions
@@ -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(
@@ -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) {
@@ -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<String> = emptyList(),
)
@Serializable
@@ -52,4 +56,9 @@ data class RouterState(
val l2Memory: List<RouterL2Entry> = emptyList(),
val conversationHistory: List<RouterTurn> = 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(),
)