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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user