From fc373e11eb10d192af117f9767d3b563344bd11b Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 14 Jun 2026 13:28:51 +0400 Subject: [PATCH] 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. --- .../core/router/RouterContextBuilder.kt | 10 ++- .../com/correx/core/router/RouterFacade.kt | 47 ++++++++++++- .../correx/core/router/WorkflowProposals.kt | 46 +++++++++++++ .../core/router/WorkflowProposalsTest.kt | 69 +++++++++++++++++++ .../test/kotlin/RouterContextBuilderTest.kt | 7 +- .../src/test/kotlin/RouterFacadeTest.kt | 67 ++++++++++++++++++ 6 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 core/router/src/main/kotlin/com/correx/core/router/WorkflowProposals.kt create mode 100644 core/router/src/test/kotlin/com/correx/core/router/WorkflowProposalsTest.kt diff --git a/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt b/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt index d694ec1b..1311c663 100644 --- a/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt +++ b/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt @@ -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":"","reason":""}],"prompt":""} + ``` + 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; diff --git a/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt b/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt index 52a24408..3a8f512c 100644 --- a/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt +++ b/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt @@ -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( diff --git a/core/router/src/main/kotlin/com/correx/core/router/WorkflowProposals.kt b/core/router/src/main/kotlin/com/correx/core/router/WorkflowProposals.kt new file mode 100644 index 00000000..2927f99b --- /dev/null +++ b/core/router/src/main/kotlin/com/correx/core/router/WorkflowProposals.kt @@ -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, + ) + + 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()) +} diff --git a/core/router/src/test/kotlin/com/correx/core/router/WorkflowProposalsTest.kt b/core/router/src/test/kotlin/com/correx/core/router/WorkflowProposalsTest.kt new file mode 100644 index 00000000..9e16ed10 --- /dev/null +++ b/core/router/src/test/kotlin/com/correx/core/router/WorkflowProposalsTest.kt @@ -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) + } +} diff --git a/testing/deterministic/src/test/kotlin/RouterContextBuilderTest.kt b/testing/deterministic/src/test/kotlin/RouterContextBuilderTest.kt index 221b191f..8b2426dc 100644 --- a/testing/deterministic/src/test/kotlin/RouterContextBuilderTest.kt +++ b/testing/deterministic/src/test/kotlin/RouterContextBuilderTest.kt @@ -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) diff --git a/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt b/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt index 8267a102..026a8850 100644 --- a/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt +++ b/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt @@ -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() + 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().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().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().isEmpty()) + } + // -------------------------------------------------------------------------- // Helpers // -------------------------------------------------------------------------- + private fun facadeProposing( + store: EventStore, + reply: String, + workflows: List, + ): 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, 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(