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
@@ -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)
}
}