feat(config): expose router generation + narration knobs in config file

The router/narration model behaviour was hardcoded (Main built RouterConfig()
with defaults). Surface it under the [router] section so it's tunable without a
rebuild:

  [router]            conversation_keep_last, retrieval_k, token_budget
  [router.generation] temperature, top_p, max_tokens          (chat/steering)
  [router.narration]  temperature, top_p, max_tokens, max_per_run

ConfigLoader parses the new sections (adds asDouble); Main maps the config-layer
RouterConfig onto the domain RouterConfig and threads narration.max_per_run into
ServerModule. All values default to the previous constants, so behaviour is
unchanged when the sections are absent. Documents the block in sample-config.toml
and adds parser tests for present/absent cases.
This commit is contained in:
2026-06-03 23:09:48 +04:00
parent e95d2633f8
commit 371e0df340
5 changed files with 141 additions and 1 deletions
@@ -14,10 +14,12 @@ import com.correx.core.config.CorrexConfig
import com.correx.core.config.ProviderConfig import com.correx.core.config.ProviderConfig
import com.correx.core.context.builder.DefaultContextPackBuilder import com.correx.core.context.builder.DefaultContextPackBuilder
import com.correx.core.context.compression.DefaultContextCompressor import com.correx.core.context.compression.DefaultContextCompressor
import com.correx.core.context.model.TokenBudget
import com.correx.core.events.EventDispatcher import com.correx.core.events.EventDispatcher
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.inference.CapabilityScore import com.correx.core.inference.CapabilityScore
import com.correx.core.inference.DefaultInferenceRouter import com.correx.core.inference.DefaultInferenceRouter
import com.correx.core.inference.GenerationConfig
import com.correx.core.inference.InferenceProjector import com.correx.core.inference.InferenceProjector
import com.correx.core.inference.InferenceRepository import com.correx.core.inference.InferenceRepository
import com.correx.core.inference.InferenceRouter import com.correx.core.inference.InferenceRouter
@@ -246,10 +248,26 @@ fun main() {
// persisted vectors survive restart but the metadata map does not (no-op for // persisted vectors survive restart but the metadata map does not (no-op for
// non-rehydratable backends like in_memory). // non-rehydratable backends like in_memory).
runBlocking { L3MetadataRehydrator(eventStore).rehydrate(l3MemoryStore) } runBlocking { L3MetadataRehydrator(eventStore).rehydrate(l3MemoryStore) }
// Map the config-file [router] block onto the domain RouterConfig the facade uses.
val domainRouterConfig = RouterConfig(
conversationKeepLast = routerConfig.conversationKeepLast,
retrievalK = routerConfig.retrievalK,
tokenBudget = TokenBudget(limit = routerConfig.tokenBudget),
generationConfig = GenerationConfig(
temperature = routerConfig.generation.temperature,
topP = routerConfig.generation.topP,
maxTokens = routerConfig.generation.maxTokens,
),
narrationGenerationConfig = GenerationConfig(
temperature = routerConfig.narration.temperature,
topP = routerConfig.narration.topP,
maxTokens = routerConfig.narration.maxTokens,
),
)
val routerFacade = InfrastructureModule.createRouterFacade( val routerFacade = InfrastructureModule.createRouterFacade(
eventStore = eventStore, eventStore = eventStore,
inferenceRouter = inferenceRouter, inferenceRouter = inferenceRouter,
config = RouterConfig(), config = domainRouterConfig,
tokenizer = firstProvider.tokenizer, tokenizer = firstProvider.tokenizer,
embedder = embedder, embedder = embedder,
l3MemoryStore = l3MemoryStore, l3MemoryStore = l3MemoryStore,
@@ -289,6 +307,7 @@ fun main() {
modelSwapper = modelSwapper, modelSwapper = modelSwapper,
resourceProbe = resourceProbe, resourceProbe = resourceProbe,
workspaceResolver = workspaceResolver, workspaceResolver = workspaceResolver,
narrationMaxPerRun = routerConfig.narration.maxPerRun,
) )
module.start() module.start()
log.info("==============================") log.info("==============================")
@@ -233,8 +233,11 @@ object ConfigLoader {
val toolsFileReadSection = sections["tools.file_read"] ?: emptyMap() val toolsFileReadSection = sections["tools.file_read"] ?: emptyMap()
val toolsFileWriteSection = sections["tools.file_write"] ?: emptyMap() val toolsFileWriteSection = sections["tools.file_write"] ?: emptyMap()
val toolsFileEditSection = sections["tools.file_edit"] ?: emptyMap() val toolsFileEditSection = sections["tools.file_edit"] ?: emptyMap()
val routerSection = sections["router"] ?: emptyMap()
val routerEmbedderSection = sections["router.embedder"] ?: emptyMap() val routerEmbedderSection = sections["router.embedder"] ?: emptyMap()
val routerL3Section = sections["router.l3"] ?: emptyMap() val routerL3Section = sections["router.l3"] ?: emptyMap()
val routerGenerationSection = sections["router.generation"] ?: emptyMap()
val routerNarrationSection = sections["router.narration"] ?: emptyMap()
val modelsSection = sections["models"] ?: emptyMap() val modelsSection = sections["models"] ?: emptyMap()
val server = ServerConfig( val server = ServerConfig(
@@ -368,9 +371,27 @@ object ConfigLoader {
bitWidth = asInt(routerL3Section["bit_width"], 4), bitWidth = asInt(routerL3Section["bit_width"], 4),
) )
val generation = GenerationSettings(
temperature = asDouble(routerGenerationSection["temperature"], 0.7),
topP = asDouble(routerGenerationSection["top_p"], 0.9),
maxTokens = asInt(routerGenerationSection["max_tokens"], 512),
)
val narration = NarrationSettings(
temperature = asDouble(routerNarrationSection["temperature"], 0.7),
topP = asDouble(routerNarrationSection["top_p"], 0.9),
maxTokens = asInt(routerNarrationSection["max_tokens"], 1024),
maxPerRun = asInt(routerNarrationSection["max_per_run"], 100),
)
val router = RouterConfig( val router = RouterConfig(
embedder = embedder, embedder = embedder,
l3 = l3, l3 = l3,
conversationKeepLast = asInt(routerSection["conversation_keep_last"], 6),
retrievalK = asInt(routerSection["retrieval_k"], 5),
tokenBudget = asInt(routerSection["token_budget"], 4096),
generation = generation,
narration = narration,
) )
val models = modelsList.mapNotNull { modelMap -> val models = modelsList.mapNotNull { modelMap ->
@@ -457,6 +478,16 @@ object ConfigLoader {
} }
} }
private fun asDouble(value: Any?, default: Double = 0.0): Double {
return when (value) {
is Double -> value
is Int -> value.toDouble()
is Long -> value.toDouble()
is String -> value.toDoubleOrNull() ?: default
else -> default
}
}
private fun asBoolean(value: Any?, default: Boolean = false): Boolean { private fun asBoolean(value: Any?, default: Boolean = false): Boolean {
return when (value) { return when (value) {
is Boolean -> value is Boolean -> value
@@ -89,6 +89,29 @@ data class ProviderConfig(
data class RouterConfig( data class RouterConfig(
val embedder: EmbedderConfig = EmbedderConfig(), val embedder: EmbedderConfig = EmbedderConfig(),
val l3: L3Config = L3Config(), val l3: L3Config = L3Config(),
val conversationKeepLast: Int = 6,
val retrievalK: Int = 5,
val tokenBudget: Int = 4096,
val generation: GenerationSettings = GenerationSettings(),
val narration: NarrationSettings = NarrationSettings(),
)
/** Sampling/length knobs for router chat + steering replies. */
@Serializable
data class GenerationSettings(
val temperature: Double = 0.7,
val topP: Double = 0.9,
val maxTokens: Int = 512,
)
/** Router narration knobs. Larger maxTokens than chat so reasoning models can
* finish "thinking" and still emit the line; maxPerRun caps narrations per run. */
@Serializable
data class NarrationSettings(
val temperature: Double = 0.7,
val topP: Double = 0.9,
val maxTokens: Int = 1024,
val maxPerRun: Int = 100,
) )
@Serializable @Serializable
@@ -415,4 +415,51 @@ class ConfigLoaderTest {
assertEquals("/home/user/projects", result.tools.allowedWorkspaceRoots[0]) assertEquals("/home/user/projects", result.tools.allowedWorkspaceRoots[0])
assertEquals("/tmp/work", result.tools.allowedWorkspaceRoots[1]) assertEquals("/tmp/work", result.tools.allowedWorkspaceRoots[1])
} }
@Test
fun `parseToml parses router generation and narration knobs`() {
val toml = """
[router]
conversation_keep_last = 8
retrieval_k = 3
token_budget = 8192
[router.generation]
temperature = 0.4
top_p = 0.8
max_tokens = 600
[router.narration]
temperature = 0.55
top_p = 0.95
max_tokens = 2048
max_per_run = 25
""".trimIndent()
val parseTomlMethod = ConfigLoader::class.java.getDeclaredMethod("parseToml", String::class.java)
parseTomlMethod.isAccessible = true
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
assertEquals(8, result.router.conversationKeepLast)
assertEquals(3, result.router.retrievalK)
assertEquals(8192, result.router.tokenBudget)
assertEquals(0.4, result.router.generation.temperature)
assertEquals(0.8, result.router.generation.topP)
assertEquals(600, result.router.generation.maxTokens)
assertEquals(0.55, result.router.narration.temperature)
assertEquals(2048, result.router.narration.maxTokens)
assertEquals(25, result.router.narration.maxPerRun)
}
@Test
fun `parseToml uses router defaults when sections absent`() {
val parseTomlMethod = ConfigLoader::class.java.getDeclaredMethod("parseToml", String::class.java)
parseTomlMethod.isAccessible = true
val result = parseTomlMethod.invoke(ConfigLoader, "[server]\nhost = \"x\"") as CorrexConfig
assertEquals(6, result.router.conversationKeepLast)
assertEquals(512, result.router.generation.maxTokens)
assertEquals(1024, result.router.narration.maxTokens)
assertEquals(100, result.router.narration.maxPerRun)
}
} }
+20
View File
@@ -74,6 +74,26 @@ capabilities = { General = 1.0, Coding = 0.7, Reasoning = 0.6, Summarization = 0
# capabilities = { General = 1.0, Coding = 0.7, Reasoning = 0.6, Summarization = 0.8, ToolCalling = 0.5 } # capabilities = { General = 1.0, Coding = 0.7, Reasoning = 0.6, Summarization = 0.8, ToolCalling = 0.5 }
# Router configuration (optional, defaults shown below) # Router configuration (optional, defaults shown below)
[router]
conversation_keep_last = 6 # how many recent chat turns to keep in context
retrieval_k = 5 # L3 memory hits to retrieve per query
token_budget = 4096 # context budget for router prompts
# Sampling/length for router chat + steering replies.
[router.generation]
temperature = 0.7
top_p = 0.9
max_tokens = 512
# Router narration (the live workflow commentary). max_tokens is higher than chat so
# reasoning models can finish "thinking" and still emit the line; max_per_run caps how
# many narrations fire per workflow run.
[router.narration]
temperature = 0.7
top_p = 0.9
max_tokens = 1024
max_per_run = 100
[router.embedder] [router.embedder]
backend = "noop" # or "llamacpp" backend = "noop" # or "llamacpp"
dimension = 1536 dimension = 1536