feat(router): proper grounded system prompts for chat and narration

Replace the placeholder "You are a routing assistant" one-liner with two
purpose-written prompts: a conversational prompt that frames the router as
correx's operator-facing interface to the event-sourced engine (grounded in
workflow state, no fabrication, concise, steering-aware), and a dedicated
narrator prompt for buildNarrationContext that asks for one or two present-tense
sentences naming the stage and outcome.

The longer protected frame shifted token budgets, so the two L2 eviction tests
now size their budget from the measured protected-frame + per-entry cost instead
of hard-coded magic numbers, making them robust to prompt length.
This commit is contained in:
2026-06-03 22:44:32 +04:00
parent ad3ec1965d
commit e6084dcbda
2 changed files with 47 additions and 12 deletions
@@ -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",
)
}