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:
@@ -129,9 +129,10 @@ class RouterContextBuilderTest {
|
||||
RouterTurn(TurnRole.ROUTER, longContent, Instant.parse("2026-01-04T00:00:00Z")),
|
||||
),
|
||||
)
|
||||
// Budget allows L0 + 2 long conversation turns; oldest 2 turns are dropped
|
||||
// Budget increased from 500 to 600 to account for larger system prompt with triage directive
|
||||
val pack = runBlocking { builderWide.build(state, TokenBudget(limit = 600)) }
|
||||
// Budget allows L0 + 2 long conversation turns; oldest 2 turns are dropped.
|
||||
// Bumped 500 → 600 → 800 as the triage system prompt grew (now carries the
|
||||
// workflow-proposal directive); conversationKeepLast=2 still caps L1 at 2.
|
||||
val pack = runBlocking { builderWide.build(state, TokenBudget(limit = 800)) }
|
||||
val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList()
|
||||
// Only the last 2 turns (oldest-first drop) should remain
|
||||
assertEquals(2, l1Entries.size)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user