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:
@@ -61,8 +61,14 @@ class DefaultRouterContextBuilder(
|
|||||||
|
|
||||||
When the operator's request is not something you can answer or steer directly:
|
When the operator's request is not something you can answer or steer directly:
|
||||||
do not simply refuse. Triage instead, citing only what appears in your context:
|
do not simply refuse. Triage instead, citing only what appears in your context:
|
||||||
(a) if "## Available workflows" is present, suggest the best-fitting workflow and
|
(a) if "## Available workflows" is present, suggest the best-fitting workflow(s) and
|
||||||
say what it would accomplish for this request;
|
say in prose what each would accomplish for this request. Then, on a final line,
|
||||||
|
append a fenced json block offering them as a picker:
|
||||||
|
```json
|
||||||
|
{"propose_workflows":[{"id":"<workflow-id>","reason":"<one line>"}],"prompt":"<short question>"}
|
||||||
|
```
|
||||||
|
Use ONLY ids from "## Available workflows"; list them best-first. The operator
|
||||||
|
gets a choice panel (with a manual-answer slot) — omit the block entirely if none fit;
|
||||||
(b) if "## Project profile" is present and the request relates to the project,
|
(b) if "## Project profile" is present and the request relates to the project,
|
||||||
point at the relevant conventions or commands;
|
point at the relevant conventions or commands;
|
||||||
(c) if recalled cross-session memory shows similar prior work, say so and reference it;
|
(c) if recalled cross-session memory shows similar prior work, say so and reference it;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.correx.core.events.events.L3RetrievedHit
|
|||||||
import com.correx.core.events.events.NewEvent
|
import com.correx.core.events.events.NewEvent
|
||||||
import com.correx.core.events.events.RouterNarrationEvent
|
import com.correx.core.events.events.RouterNarrationEvent
|
||||||
import com.correx.core.events.events.SteeringNoteAddedEvent
|
import com.correx.core.events.events.SteeringNoteAddedEvent
|
||||||
|
import com.correx.core.events.events.WorkflowProposedEvent
|
||||||
import com.correx.core.events.stores.EventStore
|
import com.correx.core.events.stores.EventStore
|
||||||
import com.correx.core.events.types.EventId
|
import com.correx.core.events.types.EventId
|
||||||
import com.correx.core.events.types.InferenceRequestId
|
import com.correx.core.events.types.InferenceRequestId
|
||||||
@@ -155,9 +156,12 @@ class DefaultRouterFacade(
|
|||||||
)
|
)
|
||||||
val inferenceResponse = provider.infer(inferenceRequest)
|
val inferenceResponse = provider.infer(inferenceRequest)
|
||||||
val rawContent = inferenceResponse.text
|
val rawContent = inferenceResponse.text
|
||||||
|
// The triage path may append a structured workflow proposal; strip it from the visible turn
|
||||||
|
// so the operator reads prose, then record the candidates as a WorkflowProposedEvent below.
|
||||||
|
val proposal = WorkflowProposals.parse(rawContent)
|
||||||
// A length-finish with no visible text means the model burned the whole completion
|
// A length-finish with no visible text means the model burned the whole completion
|
||||||
// budget on hidden reasoning — surface that instead of rendering a silent blank turn.
|
// budget on hidden reasoning — surface that instead of rendering a silent blank turn.
|
||||||
val content = rawContent.ifBlank {
|
val content = proposal.cleanText.ifBlank {
|
||||||
if (inferenceResponse.finishReason is FinishReason.Length) {
|
if (inferenceResponse.finishReason is FinishReason.Length) {
|
||||||
"The model used its entire ${config.generationConfig.maxTokens}-token response budget " +
|
"The model used its entire ${config.generationConfig.maxTokens}-token response budget " +
|
||||||
"without producing an answer (likely spent on hidden reasoning). " +
|
"without producing an answer (likely spent on hidden reasoning). " +
|
||||||
@@ -174,6 +178,10 @@ class DefaultRouterFacade(
|
|||||||
tokensUsed = inferenceResponse.tokensUsed,
|
tokensUsed = inferenceResponse.tokensUsed,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (mode == ChatMode.CHAT) {
|
||||||
|
emitWorkflowProposalIfAny(sessionId, input, proposal)
|
||||||
|
}
|
||||||
|
|
||||||
val steeringEmitted = mode == ChatMode.STEERING && rawContent.isNotBlank()
|
val steeringEmitted = mode == ChatMode.STEERING && rawContent.isNotBlank()
|
||||||
if (steeringEmitted) {
|
if (steeringEmitted) {
|
||||||
val validationError = validateSteering?.invoke(content)
|
val validationError = validateSteering?.invoke(content)
|
||||||
@@ -284,6 +292,43 @@ class DefaultRouterFacade(
|
|||||||
return turnId
|
return turnId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a [WorkflowProposedEvent] when the triage reply proposed at least one *known* workflow.
|
||||||
|
* Candidate ids are validated against the registered workflows (invariant #7 — LLM output is
|
||||||
|
* untrusted), so an invented id never reaches the operator's picker.
|
||||||
|
*/
|
||||||
|
private suspend fun emitWorkflowProposalIfAny(
|
||||||
|
sessionId: SessionId,
|
||||||
|
originalRequest: String,
|
||||||
|
proposal: WorkflowProposals.Parsed,
|
||||||
|
) {
|
||||||
|
if (proposal.candidates.isEmpty()) return
|
||||||
|
val known = workflowSummaryProvider().mapTo(mutableSetOf()) { it.id }
|
||||||
|
val valid = proposal.candidates.filter { it.workflowId in known }
|
||||||
|
if (valid.isEmpty()) return
|
||||||
|
|
||||||
|
val now = Clock.System.now()
|
||||||
|
eventStore.append(
|
||||||
|
NewEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId(UUID.randomUUID().toString()),
|
||||||
|
sessionId = sessionId,
|
||||||
|
timestamp = now,
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
payload = WorkflowProposedEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
proposalId = UUID.randomUUID().toString(),
|
||||||
|
prompt = proposal.prompt.ifBlank { "Start one of these workflows?" },
|
||||||
|
candidates = valid,
|
||||||
|
originalRequest = originalRequest,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun emitSteeringNote(sessionId: SessionId, content: String, effectiveStageId: StageId) {
|
private suspend fun emitSteeringNote(sessionId: SessionId, content: String, effectiveStageId: StageId) {
|
||||||
val now = Clock.System.now()
|
val now = Clock.System.now()
|
||||||
eventStore.append(
|
eventStore.append(
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.correx.core.router
|
||||||
|
|
||||||
|
import com.correx.core.events.events.ProposedWorkflow
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an optional `propose_workflows` block out of a router chat reply: the triage path may end
|
||||||
|
* its prose with a fenced ```` ```json {"propose_workflows":[{"id":..,"reason":..}],"prompt":..} ``` ````
|
||||||
|
* block. Lenient and fence-tolerant — any parse failure or an absent/empty array yields no proposal.
|
||||||
|
* Returns the reply with the block stripped (so the operator sees only prose) alongside the parsed
|
||||||
|
* candidates, so the router records a structured [com.correx.core.events.events.WorkflowProposedEvent]
|
||||||
|
* while still showing a clean chat turn.
|
||||||
|
*/
|
||||||
|
object WorkflowProposals {
|
||||||
|
|
||||||
|
data class Parsed(
|
||||||
|
val cleanText: String,
|
||||||
|
val prompt: String,
|
||||||
|
val candidates: List<ProposedWorkflow>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||||
|
private val fence = Regex("```(?:json)?\\s*(.*?)```", RegexOption.DOT_MATCHES_ALL)
|
||||||
|
|
||||||
|
fun parse(content: String): Parsed = runCatching {
|
||||||
|
val match = fence.find(content) ?: return@runCatching none(content)
|
||||||
|
val obj = json.parseToJsonElement(match.groupValues[1].trim()).jsonObject
|
||||||
|
val array = obj["propose_workflows"]?.jsonArray ?: return@runCatching none(content)
|
||||||
|
val candidates = array.mapNotNull { element ->
|
||||||
|
val item = element as? JsonObject ?: return@mapNotNull null
|
||||||
|
val id = (item["id"] ?: item["workflowId"])?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
|
||||||
|
ProposedWorkflow(workflowId = id, reason = item["reason"]?.jsonPrimitive?.contentOrNull ?: "")
|
||||||
|
}
|
||||||
|
if (candidates.isEmpty()) return@runCatching none(content)
|
||||||
|
val prompt = obj["prompt"]?.jsonPrimitive?.contentOrNull ?: ""
|
||||||
|
val clean = content.removeRange(match.range).trim()
|
||||||
|
Parsed(cleanText = clean.ifBlank { prompt }, prompt = prompt, candidates = candidates)
|
||||||
|
}.getOrElse { none(content) }
|
||||||
|
|
||||||
|
private fun none(content: String) = Parsed(cleanText = content, prompt = "", candidates = emptyList())
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.correx.core.router
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class WorkflowProposalsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses a fenced proposal block and strips it from the prose`() {
|
||||||
|
val content = """
|
||||||
|
The research workflow fits best — it gathers sources and writes a report.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"propose_workflows":[{"id":"research","reason":"gather + report"}],"prompt":"Run it?"}
|
||||||
|
```
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val parsed = WorkflowProposals.parse(content)
|
||||||
|
|
||||||
|
assertEquals(1, parsed.candidates.size)
|
||||||
|
assertEquals("research", parsed.candidates.single().workflowId)
|
||||||
|
assertEquals("gather + report", parsed.candidates.single().reason)
|
||||||
|
assertEquals("Run it?", parsed.prompt)
|
||||||
|
assertTrue(parsed.cleanText.startsWith("The research workflow fits best"))
|
||||||
|
assertTrue("```" !in parsed.cleanText, "fence must be stripped from the visible turn")
|
||||||
|
assertTrue("propose_workflows" !in parsed.cleanText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multiple candidates keep order`() {
|
||||||
|
val content = """
|
||||||
|
```json
|
||||||
|
{"propose_workflows":[{"id":"a"},{"id":"b","reason":"second"}],"prompt":"pick"}
|
||||||
|
```
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val parsed = WorkflowProposals.parse(content)
|
||||||
|
|
||||||
|
assertEquals(listOf("a", "b"), parsed.candidates.map { it.workflowId })
|
||||||
|
assertEquals("", parsed.candidates.first().reason)
|
||||||
|
// The whole body was the block; clean text falls back to the prompt.
|
||||||
|
assertEquals("pick", parsed.cleanText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no block yields no proposal and untouched text`() {
|
||||||
|
val content = "Let's just think this through together in chat."
|
||||||
|
val parsed = WorkflowProposals.parse(content)
|
||||||
|
assertTrue(parsed.candidates.isEmpty())
|
||||||
|
assertEquals(content, parsed.cleanText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `malformed json yields no proposal`() {
|
||||||
|
val content = "prose\n```json\n{not valid}\n```"
|
||||||
|
val parsed = WorkflowProposals.parse(content)
|
||||||
|
assertTrue(parsed.candidates.isEmpty())
|
||||||
|
assertEquals(content, parsed.cleanText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty candidate array is not a proposal`() {
|
||||||
|
val content = "prose\n```json\n{\"propose_workflows\":[],\"prompt\":\"x\"}\n```"
|
||||||
|
val parsed = WorkflowProposals.parse(content)
|
||||||
|
assertTrue(parsed.candidates.isEmpty())
|
||||||
|
assertEquals(content, parsed.cleanText)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,9 +129,10 @@ class RouterContextBuilderTest {
|
|||||||
RouterTurn(TurnRole.ROUTER, longContent, Instant.parse("2026-01-04T00:00:00Z")),
|
RouterTurn(TurnRole.ROUTER, longContent, Instant.parse("2026-01-04T00:00:00Z")),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Budget allows L0 + 2 long conversation turns; oldest 2 turns are dropped
|
// 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
|
// Bumped 500 → 600 → 800 as the triage system prompt grew (now carries the
|
||||||
val pack = runBlocking { builderWide.build(state, TokenBudget(limit = 600)) }
|
// 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()
|
val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList()
|
||||||
// Only the last 2 turns (oldest-first drop) should remain
|
// Only the last 2 turns (oldest-first drop) should remain
|
||||||
assertEquals(2, l1Entries.size)
|
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.L3RetrievedHit
|
||||||
import com.correx.core.events.events.NewEvent
|
import com.correx.core.events.events.NewEvent
|
||||||
import com.correx.core.events.events.StoredEvent
|
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.stores.EventStore
|
||||||
import com.correx.core.events.types.ContextPackId
|
import com.correx.core.events.types.ContextPackId
|
||||||
import com.correx.core.events.types.EventId
|
import com.correx.core.events.types.EventId
|
||||||
@@ -616,10 +617,76 @@ class RouterFacadeTest {
|
|||||||
assertNull(event.tokensUsed)
|
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
|
// 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 mockEventStore(): MapBackedEventStore = MapBackedEventStore()
|
||||||
|
|
||||||
private fun facadeWithMocks(
|
private fun facadeWithMocks(
|
||||||
|
|||||||
Reference in New Issue
Block a user