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 de193d30..b017e34b 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 @@ -17,11 +17,17 @@ import com.correx.core.router.model.RouterL2Entry import com.correx.core.router.model.RouterState import com.correx.core.router.model.RouterTurn import com.correx.core.router.model.TurnRole +import com.correx.core.router.model.WorkflowSummary import java.util.* import kotlinx.coroutines.CancellationException interface RouterContextBuilder { - suspend fun build(state: RouterState, budget: TokenBudget): ContextPack + suspend fun build( + state: RouterState, + budget: TokenBudget, + availableWorkflows: List = emptyList(), + projectProfileText: String? = null, + ): ContextPack /** * Builds a minimal [ContextPack] for a narration inference call. @@ -32,7 +38,7 @@ interface RouterContextBuilder { * without needing a stub override. */ suspend fun buildNarrationContext(state: RouterState, trigger: NarrationTrigger, budget: TokenBudget): ContextPack = - build(state, budget) + build(state, budget, emptyList(), null) } class DefaultRouterContextBuilder( @@ -68,7 +74,12 @@ class DefaultRouterContextBuilder( private const val RECALLED_MEMORY_PREFIX = "[recalled memory]" } - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack { + override suspend fun build( + state: RouterState, + budget: TokenBudget, + availableWorkflows: List, + projectProfileText: String?, + ): ContextPack { // Protected frame: system prompt and workflow status are ALWAYS included regardless of budget. val systemPrompt = buildContextEntry( sourceType = "systemPrompt", @@ -84,14 +95,39 @@ class DefaultRouterContextBuilder( layer = ContextLayer.L0, role = EntryRole.SYSTEM, ) + val availableWorkflowsEntry: ContextEntry? = availableWorkflows + .takeIf { it.isNotEmpty() } + ?.let { + buildContextEntry( + sourceType = "availableWorkflows", + sourceId = "available-workflows", + content = buildAvailableWorkflowsContent(it), + layer = ContextLayer.L0, + role = EntryRole.SYSTEM, + ) + } + val projectProfileEntry: ContextEntry? = projectProfileText + ?.takeIf { it.isNotBlank() } + ?.let { + buildContextEntry( + sourceType = "projectProfile", + sourceId = "project-profile", + content = it, + layer = ContextLayer.L0, + role = EntryRole.SYSTEM, + ) + } // Compute remaining budget after protected frame. - val protectedTokens = systemPrompt.tokenEstimate + workflowStatusEntry.tokenEstimate + val protectedTokens = systemPrompt.tokenEstimate + workflowStatusEntry.tokenEstimate + + (availableWorkflowsEntry?.tokenEstimate ?: 0) + (projectProfileEntry?.tokenEstimate ?: 0) var remainingBudget = (budget.limit - protectedTokens).coerceAtLeast(0) val allEntries = mutableListOf() allEntries += systemPrompt allEntries += workflowStatusEntry + availableWorkflowsEntry?.let { allEntries += it } + projectProfileEntry?.let { allEntries += it } var droppedCount = 0 val truncatedLayers = mutableSetOf() @@ -258,6 +294,14 @@ class DefaultRouterContextBuilder( return base + reason + steering } + private fun buildAvailableWorkflowsContent(workflows: List): String = + buildString { + append("## Available workflows\n") + workflows.forEach { wf -> + append("- ${wf.id}: ${wf.description} (stages: ${wf.stageIds.joinToString(" → ")})\n") + } + }.trimEnd() + private suspend fun buildContextEntry( sourceType: String, sourceId: String, diff --git a/core/router/src/main/kotlin/com/correx/core/router/model/WorkflowSummary.kt b/core/router/src/main/kotlin/com/correx/core/router/model/WorkflowSummary.kt new file mode 100644 index 00000000..711be304 --- /dev/null +++ b/core/router/src/main/kotlin/com/correx/core/router/model/WorkflowSummary.kt @@ -0,0 +1,7 @@ +package com.correx.core.router.model + +data class WorkflowSummary( + val id: String, + val description: String, + val stageIds: List, +) diff --git a/testing/deterministic/src/test/kotlin/RouterContextBuilderTest.kt b/testing/deterministic/src/test/kotlin/RouterContextBuilderTest.kt index 42de1914..d6a46a69 100644 --- a/testing/deterministic/src/test/kotlin/RouterContextBuilderTest.kt +++ b/testing/deterministic/src/test/kotlin/RouterContextBuilderTest.kt @@ -7,6 +7,7 @@ import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.router.DefaultRouterContextBuilder import com.correx.core.router.model.RouterConfig +import com.correx.core.router.model.WorkflowSummary import com.correx.core.router.model.RouterL2Entry import com.correx.core.router.model.RouterState import com.correx.core.router.model.RouterTurn @@ -19,6 +20,7 @@ import kotlinx.datetime.Instant import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -805,6 +807,40 @@ class RouterContextBuilderTest { assertTrue(pack.compressionMetadata.entriesDropped >= 2) } + // -------------------------------------------------------------------------- + // A2: availableWorkflows and projectProfileText L0 injection + // -------------------------------------------------------------------------- + + @Test + fun `available workflows entry is injected into L0 when non-empty`() { + val workflows = listOf(WorkflowSummary("healthcheck", "Runs the health check pipeline", listOf("collect", "report"))) + val pack = runBlocking { builder.build(RouterState(), TokenBudget(limit = 10000), availableWorkflows = workflows) } + val entry = (pack.layers[ContextLayer.L0] ?: emptyList()).find { it.sourceType == "availableWorkflows" } + assertNotNull(entry) + assertTrue(entry!!.content.contains("## Available workflows")) + assertTrue(entry.content.contains("healthcheck: Runs the health check pipeline (stages: collect → report)")) + } + + @Test + fun `available workflows entry is omitted when list is empty`() { + val pack = runBlocking { builder.build(RouterState(), TokenBudget(limit = 10000)) } + assertNull((pack.layers[ContextLayer.L0] ?: emptyList()).find { it.sourceType == "availableWorkflows" }) + } + + @Test + fun `project profile text is injected into L0 when non-null`() { + val text = "## Project profile\nA Kotlin service.\n### Conventions\n- Use runCatching" + val pack = runBlocking { builder.build(RouterState(), TokenBudget(limit = 10000), projectProfileText = text) } + val entry = (pack.layers[ContextLayer.L0] ?: emptyList()).find { it.sourceType == "projectProfile" } + assertEquals(text, entry!!.content) + } + + @Test + fun `project profile text is omitted when null or blank`() { + val pack = runBlocking { builder.build(RouterState(), TokenBudget(limit = 10000), projectProfileText = " ") } + assertNull((pack.layers[ContextLayer.L0] ?: emptyList()).find { it.sourceType == "projectProfile" }) + } + @Test fun `truncatedLayers populated with affected layers when drops occur`() { val longText = "x".repeat(800) diff --git a/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt b/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt index f3b2b6c0..319bb3a7 100644 --- a/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt +++ b/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt @@ -45,6 +45,7 @@ import com.correx.core.router.model.RouterResponse import com.correx.core.router.model.RouterState import com.correx.core.router.model.RouterTurn import com.correx.core.router.model.TurnRole +import com.correx.core.router.model.WorkflowSummary import com.correx.core.router.model.WorkflowStatus import com.correx.core.sessions.projections.replay.DefaultEventReplayer import com.correx.testing.fixtures.EventFixtures @@ -142,7 +143,7 @@ class RouterFacadeTest { ) }, routerContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack() + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter("steering response"), eventStore = mockStore, @@ -175,7 +176,7 @@ class RouterFacadeTest { replayer.rebuild(sessionId) }, routerContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack { + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack { capturedStates.add(state) return emptyContextPack() } @@ -215,7 +216,7 @@ class RouterFacadeTest { replayer.rebuild(sessionId) }, routerContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack { + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack { capturedStates.add(state) return emptyContextPack() } @@ -245,7 +246,7 @@ class RouterFacadeTest { fun `state is passed through to context builder`(): Unit = runBlocking { val capturedState = mutableListOf() val mockContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack { + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack { capturedState.add(state) return emptyContextPack() } @@ -276,7 +277,7 @@ class RouterFacadeTest { fun `budget is passed through to context builder`(): Unit = runBlocking { val capturedBudget = mutableListOf() val mockContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack { + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack { capturedBudget.add(budget) return emptyContextPack() } @@ -319,7 +320,7 @@ class RouterFacadeTest { ) }, routerContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack() + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter, eventStore = mockEventStore(), @@ -352,7 +353,7 @@ class RouterFacadeTest { ) }, routerContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack() + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter, eventStore = mockEventStore(), @@ -380,7 +381,7 @@ class RouterFacadeTest { override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState() }, routerContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack() + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter, eventStore = mockEventStore(), @@ -410,7 +411,7 @@ class RouterFacadeTest { override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState() }, routerContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack() + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter, eventStore = mockEventStore(), @@ -433,7 +434,7 @@ class RouterFacadeTest { override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState() }, routerContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack { + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack { val pack = ContextPack( id = ContextPackId("test-pack"), sessionId = state.sessionId ?: SessionId("unknown"), @@ -490,7 +491,7 @@ class RouterFacadeTest { override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState() }, routerContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack() + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = object : InferenceRouter { override suspend fun route( @@ -578,7 +579,7 @@ class RouterFacadeTest { override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState() }, routerContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack() + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = object : InferenceRouter { override suspend fun route(stageId: StageId, requiredCapabilities: Set): InferenceProvider = @@ -636,7 +637,7 @@ class RouterFacadeTest { ) }, routerContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack() + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter("inference response"), eventStore = eventStore, @@ -769,7 +770,7 @@ class RouterFacadeTest { replayer.rebuild(sessionId) }, routerContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack() + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter("router reply"), eventStore = eventStore, @@ -847,7 +848,7 @@ class RouterFacadeTest { replayer.rebuild(sessionId) }, routerContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack() + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter("router reply"), eventStore = eventStore, @@ -878,7 +879,7 @@ class RouterFacadeTest { override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState() }, routerContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack() + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter("response"), eventStore = eventStore, @@ -1093,7 +1094,7 @@ class RouterFacadeTest { private val real = DefaultRouterContextBuilder( config = RouterConfig(tokenBudget = TokenBudget(limit = 10000)), ) - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack { + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack { capturedStates.add(state) return real.build(state, budget) } @@ -1165,7 +1166,7 @@ class RouterFacadeTest { private val real = DefaultRouterContextBuilder( config = RouterConfig(tokenBudget = TokenBudget(limit = 10000)), ) - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = real.build(state, budget).also { capturedPacks.add(it) } }, inferenceRouter = mockInferenceRouter("router reply"), @@ -1213,7 +1214,7 @@ class RouterFacadeTest { override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState() }, routerContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack() + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter, eventStore = mockEventStore(), @@ -1258,7 +1259,7 @@ class RouterFacadeTest { override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState() }, routerContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack() + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter, eventStore = mockEventStore(), diff --git a/testing/deterministic/src/test/kotlin/RouterNarrationTest.kt b/testing/deterministic/src/test/kotlin/RouterNarrationTest.kt index b7f893e4..4a0a7b52 100644 --- a/testing/deterministic/src/test/kotlin/RouterNarrationTest.kt +++ b/testing/deterministic/src/test/kotlin/RouterNarrationTest.kt @@ -37,6 +37,7 @@ import com.correx.core.router.model.NarrationTrigger import com.correx.core.router.model.RouterConfig import com.correx.core.router.model.RouterState import com.correx.core.router.model.WorkflowStatus +import com.correx.core.router.model.WorkflowSummary import com.correx.testing.fixtures.inference.MockTokenizer import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.runBlocking @@ -141,7 +142,7 @@ class RouterNarrationTest { ) }, routerContextBuilder = object : RouterContextBuilder { - override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack() + override suspend fun build(state: RouterState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() override suspend fun buildNarrationContext(state: RouterState, trigger: NarrationTrigger, budget: TokenBudget): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter("stage narration", latencyMs = 10L, tokensUsed = TokenUsage(1, 1)),