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
@@ -61,8 +61,14 @@ class DefaultRouterContextBuilder(
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:
(a) if "## Available workflows" is present, suggest the best-fitting workflow and
say what it would accomplish for this request;
(a) if "## Available workflows" is present, suggest the best-fitting workflow(s) and
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,
point at the relevant conventions or commands;
(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.RouterNarrationEvent
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.types.EventId
import com.correx.core.events.types.InferenceRequestId
@@ -155,9 +156,12 @@ class DefaultRouterFacade(
)
val inferenceResponse = provider.infer(inferenceRequest)
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
// 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) {
"The model used its entire ${config.generationConfig.maxTokens}-token response budget " +
"without producing an answer (likely spent on hidden reasoning). " +
@@ -174,6 +178,10 @@ class DefaultRouterFacade(
tokensUsed = inferenceResponse.tokensUsed,
)
if (mode == ChatMode.CHAT) {
emitWorkflowProposalIfAny(sessionId, input, proposal)
}
val steeringEmitted = mode == ChatMode.STEERING && rawContent.isNotBlank()
if (steeringEmitted) {
val validationError = validateSteering?.invoke(content)
@@ -284,6 +292,43 @@ class DefaultRouterFacade(
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) {
val now = Clock.System.now()
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)
}
}