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.context.builder.DefaultContextPackBuilder
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.stores.EventStore
import com.correx.core.inference.CapabilityScore
import com.correx.core.inference.DefaultInferenceRouter
import com.correx.core.inference.GenerationConfig
import com.correx.core.inference.InferenceProjector
import com.correx.core.inference.InferenceRepository
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
// non-rehydratable backends like in_memory).
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(
eventStore = eventStore,
inferenceRouter = inferenceRouter,
config = RouterConfig(),
config = domainRouterConfig,
tokenizer = firstProvider.tokenizer,
embedder = embedder,
l3MemoryStore = l3MemoryStore,
@@ -289,6 +307,7 @@ fun main() {
modelSwapper = modelSwapper,
resourceProbe = resourceProbe,
workspaceResolver = workspaceResolver,
narrationMaxPerRun = routerConfig.narration.maxPerRun,
)
module.start()
log.info("==============================")
@@ -233,8 +233,11 @@ object ConfigLoader {
val toolsFileReadSection = sections["tools.file_read"] ?: emptyMap()
val toolsFileWriteSection = sections["tools.file_write"] ?: emptyMap()
val toolsFileEditSection = sections["tools.file_edit"] ?: emptyMap()
val routerSection = sections["router"] ?: emptyMap()
val routerEmbedderSection = sections["router.embedder"] ?: 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 server = ServerConfig(
@@ -368,9 +371,27 @@ object ConfigLoader {
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(
embedder = embedder,
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 ->
@@ -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 {
return when (value) {
is Boolean -> value
@@ -89,6 +89,29 @@ data class ProviderConfig(
data class RouterConfig(
val embedder: EmbedderConfig = EmbedderConfig(),
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
@@ -415,4 +415,51 @@ class ConfigLoaderTest {
assertEquals("/home/user/projects", result.tools.allowedWorkspaceRoots[0])
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 }
# 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]
backend = "noop" # or "llamacpp"
dimension = 1536