feat(router): emit structured workflow proposals from triage

The triage system prompt now appends a fenced json propose_workflows block;
WorkflowProposals.parse strips it from the visible chat turn and onUserInput
records a WorkflowProposedEvent (CHAT only). Candidate ids are validated against
the registered workflows (invariant #7) so an invented id never reaches the
operator. Bumped a token-budget test for the larger prompt.
This commit is contained in:
2026-06-14 13:28:51 +04:00
parent 8d4bc0b2dd
commit fc373e11eb
6 changed files with 240 additions and 6 deletions
@@ -9,6 +9,7 @@ import com.correx.core.events.events.L3MemoryRetrievedEvent
import com.correx.core.events.events.L3RetrievedHit
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.WorkflowProposedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.EventId
@@ -616,10 +617,76 @@ class RouterFacadeTest {
assertNull(event.tokensUsed)
}
@Test
fun `triage proposing a known workflow emits WorkflowProposedEvent and strips the json block`(): Unit = runBlocking {
val store = mockEventStore()
val reply = """
The research workflow fits — it gathers sources then writes a report.
```json
{"propose_workflows":[{"id":"research","reason":"gather + report"}],"prompt":"Run it?"}
```
""".trimIndent()
val facade = facadeProposing(store, reply, listOf(WorkflowSummary("research", "desc", listOf("decompose"))))
facade.onUserInput(SessionId("s"), "find me papers on event sourcing")
val payloads = store.appendedEvents.map { it.payload }
val proposed = payloads.filterIsInstance<WorkflowProposedEvent>()
assertEquals(1, proposed.size)
assertEquals(listOf("research"), proposed.single().candidates.map { it.workflowId })
assertEquals("Run it?", proposed.single().prompt)
assertEquals("find me papers on event sourcing", proposed.single().originalRequest)
val routerTurn = payloads.filterIsInstance<ChatTurnEvent>().first { it.role == ChatTurnRole.ROUTER }
assertFalse(routerTurn.content.contains("```"), "json block must be stripped from the chat turn")
assertTrue(routerTurn.content.startsWith("The research workflow fits"))
}
@Test
fun `proposed workflow ids not in the registry are dropped so no proposal is recorded`(): Unit = runBlocking {
val store = mockEventStore()
val reply = "Try this.\n```json\n{\"propose_workflows\":[{\"id\":\"nonexistent\"}],\"prompt\":\"go?\"}\n```"
val facade = facadeProposing(store, reply, listOf(WorkflowSummary("research", "d", listOf("x"))))
facade.onUserInput(SessionId("s"), "do something")
assertTrue(store.appendedEvents.map { it.payload }.filterIsInstance<WorkflowProposedEvent>().isEmpty())
}
@Test
fun `a steering turn never records a workflow proposal`(): Unit = runBlocking {
val store = mockEventStore()
val reply = "x\n```json\n{\"propose_workflows\":[{\"id\":\"research\"}],\"prompt\":\"go?\"}\n```"
val facade = facadeProposing(store, reply, listOf(WorkflowSummary("research", "d", listOf("x"))))
facade.onUserInput(SessionId("s"), "steer it", ChatMode.STEERING)
assertTrue(store.appendedEvents.map { it.payload }.filterIsInstance<WorkflowProposedEvent>().isEmpty())
}
// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------
private fun facadeProposing(
store: EventStore,
reply: String,
workflows: List<WorkflowSummary>,
): RouterFacade = DefaultRouterFacade(
routerRepository = object : RouterRepository {
override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState(sessionId = sessionId)
},
routerContextBuilder = object : RouterContextBuilder {
override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack()
},
inferenceRouter = mockInferenceRouter(reply),
eventStore = store,
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
embedder = NoopEmbedder(dimension = 8),
l3MemoryStore = InMemoryL3MemoryStore(),
workflowSummaryProvider = { workflows },
)
private fun mockEventStore(): MapBackedEventStore = MapBackedEventStore()
private fun facadeWithMocks(