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 b4d60875..de193d30 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 @@ -41,8 +41,30 @@ class DefaultRouterContextBuilder( ) : RouterContextBuilder { companion object { - private const val SYSTEM_PROMPT = - "You are a routing assistant. Provide guidance based on workflow state and conversation context." + private val SYSTEM_PROMPT = + """ + You are correx's router — the conversational interface between the operator and a + local, event-sourced workflow engine. You are not the engine: stages, tools, and + approvals are run by the kernel and recorded as events. Your job is to help the + operator understand and steer what the engine is doing. + + Ground every statement in the workflow state and conversation context you are given. + Never invent stages, tool results, file contents, or status you have not been shown; + if something is not in context, say you do not have it. Be concise and direct — plain + sentences, no filler. When the operator is steering an approval, help them state + clearly what they want changed. + """.trimIndent() + + private val NARRATION_SYSTEM_PROMPT = + """ + You are correx's router, narrating a running workflow to the operator in their live + feed. In one or two short sentences, in your own voice, say what just happened and + what comes next. Be concrete and grounded in the workflow state you are given — name + the stage, the outcome, and the reason for any failure or pause. Do not use lists, + headings, or preamble, and do not invent details you were not given. Speak in the + present, as events unfold. + """.trimIndent() + private const val RECALLED_MEMORY_PREFIX = "[recalled memory]" } @@ -288,8 +310,8 @@ class DefaultRouterContextBuilder( ): ContextPack { val systemPromptEntry = buildContextEntry( sourceType = "systemPrompt", - sourceId = "router-system", - content = SYSTEM_PROMPT, + sourceId = "router-narration-system", + content = NARRATION_SYSTEM_PROMPT, layer = ContextLayer.L0, role = EntryRole.SYSTEM, ) diff --git a/testing/deterministic/src/test/kotlin/RouterContextBuilderTest.kt b/testing/deterministic/src/test/kotlin/RouterContextBuilderTest.kt index 7918859b..42de1914 100644 --- a/testing/deterministic/src/test/kotlin/RouterContextBuilderTest.kt +++ b/testing/deterministic/src/test/kotlin/RouterContextBuilderTest.kt @@ -93,8 +93,14 @@ class RouterContextBuilderTest { RouterL2Entry(StageId("s3"), summary, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-03T00:00:00Z")), ), ) - // L0 ~30 tokens; budget 145 leaves ~115. Two entries (~55 each = ~110) fit; the third (oldest) is dropped. - val pack = runBlocking { builderNoConv.build(state, TokenBudget(limit = 145)) } + // Measure the protected frame (L0) and per-entry cost, then size the budget to fit + // exactly two of three L2 entries — robust to system-prompt length changes. + val measured = runBlocking { builderNoConv.build(state, TokenBudget(limit = 10000)) } + val l0Cost = measured.layers[ContextLayer.L0].orEmpty().sumOf { it.tokenEstimate } + val entryCost = measured.layers[ContextLayer.L2].orEmpty().first().tokenEstimate + val pack = runBlocking { + builderNoConv.build(state, TokenBudget(limit = l0Cost + entryCost * 2 + entryCost / 2)) + } val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList() val retainedIds = l2Entries.map { it.sourceId }.toSet() // Newest two survive; oldest is dropped @@ -218,9 +224,14 @@ class RouterContextBuilderTest { RouterL2Entry(StageId("s3"), summary, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-01T02:00:00Z")), ), ) - // "Stage sN (SUCCESS): " + 100x = ~30 tokens each; L0 ~30 tokens. - // Budget = L0 (30) + two entries (30+30=60) = 90. Third entry doesn't fit → oldest (s1) dropped. - val pack = runBlocking { builderTight.build(state, TokenBudget(limit = 90)) } + // Size the budget from the measured protected frame + per-entry cost to fit exactly two + // of three entries; the third (oldest, s1) is dropped. Robust to system-prompt length. + val measured = runBlocking { builderTight.build(state, TokenBudget(limit = 10000)) } + val l0Cost = measured.layers[ContextLayer.L0].orEmpty().sumOf { it.tokenEstimate } + val entryCost = measured.layers[ContextLayer.L2].orEmpty().first().tokenEstimate + val pack = runBlocking { + builderTight.build(state, TokenBudget(limit = l0Cost + entryCost * 2 + entryCost / 2)) + } val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList() val retainedIds = l2Entries.map { it.sourceId } // Oldest entry (s1) should be dropped; s2 and s3 retained in chronological order @@ -436,9 +447,11 @@ class RouterContextBuilderTest { val pack = buildPack(state, TokenBudget(limit = 10000)) val systemEntry = pack.layers[ContextLayer.L0]?.find { it.sourceType == "systemPrompt" } assertNotNull(systemEntry) - assertEquals( - "You are a routing assistant. Provide guidance based on workflow state and conversation context.", - systemEntry!!.content, + val content = systemEntry!!.content + assertTrue(content.contains("correx"), "system prompt names the product") + assertTrue( + content.contains("workflow", ignoreCase = true), + "system prompt grounds the router in workflow state", ) }