From f1fc43cc859495d4276e0e73c1250cbb7b7c1d3b Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 11 Jun 2026 13:15:32 +0400 Subject: [PATCH] feat(router,server): wire workflow inventory and project profile into router context per turn --- .../kotlin/com/correx/apps/server/Main.kt | 33 ++++++++- .../com/correx/core/router/RouterFacade.kt | 5 +- .../infrastructure/InfrastructureModule.kt | 6 +- .../src/test/kotlin/RouterFacadeTest.kt | 71 +++++++++++++++++++ 4 files changed, 110 insertions(+), 5 deletions(-) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 249c9619..58093d7a 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -292,6 +292,9 @@ fun main() { // persisted vectors survive restart but the metadata map does not (no-op for // non-rehydratable backends like in_memory). runBlocking { L3MetadataRehydrator(eventStore).rehydrate(l3MemoryStore) } + val workflowRegistry = FileSystemWorkflowRegistry( + InfrastructureModule.createWorkflowLoader(configArtifactKindsEarly), + ) // Builds the router facade from a config snapshot, mapping the [router] block onto the domain // RouterConfig. Reused by ConfigService's rebuild hook so router knob edits apply live (the // embedder and L3 store are construction-time and reused; the facade is what gets swapped). @@ -320,6 +323,16 @@ fun main() { tokenizer = firstProvider.tokenizer, embedder = embedder, l3MemoryStore = l3MemoryStore, + workflowSummaryProvider = { + workflowRegistry.listAll().map { s -> + com.correx.core.router.model.WorkflowSummary(s.workflowId, s.description, s.stageIds) + } + }, + sessionProfileProvider = { sid -> + runCatching { repositories.sessionRepository.getSession(sid).state.boundProjectProfile } + .getOrNull() + ?.let { renderProjectProfileText(it) } + }, ) } val routerFacade = buildRouterFacade(correxConfig) @@ -383,9 +396,7 @@ fun main() { eventStore = eventStore, artifactStore = artifactStore, sessionRepository = repositories.sessionRepository, - workflowRegistry = FileSystemWorkflowRegistry( - InfrastructureModule.createWorkflowLoader(configArtifactKindsEarly), - ), + workflowRegistry = workflowRegistry, providerRegistry = infraRegistry.asServerRegistry(), defaultOrchestrationConfig = defaultOrchestrationConfig, routerFacade = routerFacade, @@ -418,6 +429,22 @@ fun main() { embeddedServer(Netty, port = 8080) { configureServer(module) }.start(wait = true) } +fun renderProjectProfileText(profile: com.correx.core.sessions.BoundProjectProfile?): String? { + if (profile == null) return null + return buildString { + append("## Project profile\n") + if (profile.about.isNotBlank()) append(profile.about).append("\n") + if (profile.conventions.isNotEmpty()) { + append("### Conventions\n") + profile.conventions.forEach { append("- ").append(it).append("\n") } + } + if (profile.commands.isNotEmpty()) { + append("### Commands\n") + profile.commands.forEach { (name, cmd) -> append(name).append(": ").append(cmd).append("\n") } + } + }.trimEnd().takeIf { it.isNotBlank() } +} + private fun logModelInfo( modelId: String, infraRegistry: DefaultProviderRegistry, 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 9eb974c2..2f7b1116 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 @@ -26,6 +26,7 @@ import com.correx.core.router.l3.L3Query import com.correx.core.router.model.NarrationTrigger import com.correx.core.router.model.RouterConfig import com.correx.core.router.model.RouterResponse +import com.correx.core.router.model.WorkflowSummary import kotlinx.coroutines.CancellationException import kotlinx.datetime.Clock import org.slf4j.LoggerFactory @@ -52,6 +53,8 @@ class DefaultRouterFacade( private val validateSteering: (suspend (String) -> String?)? = null, private val embedder: Embedder, private val l3MemoryStore: L3MemoryStore, + private val workflowSummaryProvider: () -> List = { emptyList() }, + private val sessionProfileProvider: suspend (SessionId) -> String? = { null }, ) : RouterFacade { override suspend fun onUserInput( @@ -115,7 +118,7 @@ class DefaultRouterFacade( // Rebuild state so lastRetrievedMemory reflects this turn state = routerRepository.getRouterState(sessionId) - val contextPack = routerContextBuilder.build(state, config.tokenBudget) + val contextPack = routerContextBuilder.build(state, config.tokenBudget, workflowSummaryProvider(), sessionProfileProvider(sessionId)) if (contextPack.compressionMetadata.entriesDropped > 0) { val truncNow = Clock.System.now() diff --git a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt index 30e22581..de037984 100644 --- a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt +++ b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt @@ -31,6 +31,8 @@ import com.correx.core.router.RouterProjector import com.correx.core.router.l3.InMemoryL3MemoryStore import com.correx.core.router.l3.L3MemoryStore import com.correx.core.router.model.RouterConfig +import com.correx.core.router.model.WorkflowSummary +import com.correx.core.events.types.SessionId import com.correx.core.sessions.projections.replay.DefaultEventReplayer import com.correx.core.tools.contract.ToolExecutor import com.correx.core.tools.registry.ToolRegistry @@ -291,12 +293,14 @@ object InfrastructureModule { tokenizer: Tokenizer? = null, embedder: Embedder = createNoopEmbedder(), l3MemoryStore: L3MemoryStore = createInMemoryL3MemoryStore(), + workflowSummaryProvider: () -> List = { emptyList() }, + sessionProfileProvider: suspend (SessionId) -> String? = { null }, ): RouterFacade { val reducer = DefaultRouterReducer() val projector = RouterProjector(reducer) val replayer = DefaultEventReplayer(eventStore, projector) val repository = DefaultRouterRepository(replayer) val contextBuilder = DefaultRouterContextBuilder(config, tokenizer) - return DefaultRouterFacade(repository, contextBuilder, inferenceRouter, eventStore, config, null, embedder, l3MemoryStore) + return DefaultRouterFacade(repository, contextBuilder, inferenceRouter, eventStore, config, null, embedder, l3MemoryStore, workflowSummaryProvider, sessionProfileProvider) } } diff --git a/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt b/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt index 319bb3a7..8267a102 100644 --- a/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt +++ b/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt @@ -1274,6 +1274,77 @@ class RouterFacadeTest { assertEquals(0, threeArgCallCount, "onUserInput must not call 3-arg route()") } + // -------------------------------------------------------------------------- + // A4 — workflowSummaryProvider and sessionProfileProvider forwarding + // -------------------------------------------------------------------------- + + @Test + fun `workflowSummaryProvider is called and forwarded to context builder on each turn`(): Unit = runBlocking { + val captured = mutableListOf>() + val capturingBuilder = object : RouterContextBuilder { + override suspend fun build( + state: RouterState, + budget: TokenBudget, + availableWorkflows: List, + projectProfileText: String?, + ): ContextPack { + captured.add(availableWorkflows) + return emptyContextPack() + } + } + val workflow = WorkflowSummary("wf", "desc", listOf("s1")) + val facade = facadeWith(contextBuilder = capturingBuilder, workflowSummaryProvider = { listOf(workflow) }) + facade.onUserInput(SessionId("s"), "hello") + assertEquals(listOf(listOf(workflow)), captured) + } + + @Test + fun `sessionProfileProvider is called and forwarded to context builder`(): Unit = runBlocking { + val capturedProfiles = mutableListOf() + val capturingBuilder = object : RouterContextBuilder { + override suspend fun build( + state: RouterState, + budget: TokenBudget, + availableWorkflows: List, + projectProfileText: String?, + ): ContextPack { + capturedProfiles.add(projectProfileText) + return emptyContextPack() + } + } + val facade = facadeWith( + contextBuilder = capturingBuilder, + sessionProfileProvider = { "profile text" }, + ) + facade.onUserInput(SessionId("s"), "hello") + assertEquals(listOf("profile text"), capturedProfiles) + } + + private fun facadeWith( + contextBuilder: RouterContextBuilder = object : RouterContextBuilder { + override suspend fun build( + state: RouterState, + budget: TokenBudget, + availableWorkflows: List, + projectProfileText: String?, + ): ContextPack = emptyContextPack() + }, + workflowSummaryProvider: () -> List = { emptyList() }, + sessionProfileProvider: suspend (SessionId) -> String? = { null }, + ): RouterFacade = DefaultRouterFacade( + routerRepository = object : RouterRepository { + override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState(sessionId = sessionId) + }, + routerContextBuilder = contextBuilder, + inferenceRouter = mockInferenceRouter("inference response"), + eventStore = mockEventStore(), + config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), + embedder = NoopEmbedder(dimension = 8), + l3MemoryStore = InMemoryL3MemoryStore(), + workflowSummaryProvider = workflowSummaryProvider, + sessionProfileProvider = sessionProfileProvider, + ) + private fun emptyContextPack(): ContextPack = ContextPack( id = ContextPackId("empty"), sessionId = SessionId("unknown"),