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 {
|
) : RouterContextBuilder {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val SYSTEM_PROMPT =
|
private val SYSTEM_PROMPT =
|
||||||
"You are a routing assistant. Provide guidance based on workflow state and conversation context."
|
"""
|
||||||
|
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]"
|
private const val RECALLED_MEMORY_PREFIX = "[recalled memory]"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,8 +310,8 @@ class DefaultRouterContextBuilder(
|
|||||||
): ContextPack {
|
): ContextPack {
|
||||||
val systemPromptEntry = buildContextEntry(
|
val systemPromptEntry = buildContextEntry(
|
||||||
sourceType = "systemPrompt",
|
sourceType = "systemPrompt",
|
||||||
sourceId = "router-system",
|
sourceId = "router-narration-system",
|
||||||
content = SYSTEM_PROMPT,
|
content = NARRATION_SYSTEM_PROMPT,
|
||||||
layer = ContextLayer.L0,
|
layer = ContextLayer.L0,
|
||||||
role = EntryRole.SYSTEM,
|
role = EntryRole.SYSTEM,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -93,8 +93,14 @@ class RouterContextBuilderTest {
|
|||||||
RouterL2Entry(StageId("s3"), summary, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-03T00:00:00Z")),
|
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.
|
// Measure the protected frame (L0) and per-entry cost, then size the budget to fit
|
||||||
val pack = runBlocking { builderNoConv.build(state, TokenBudget(limit = 145)) }
|
// 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 l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
|
||||||
val retainedIds = l2Entries.map { it.sourceId }.toSet()
|
val retainedIds = l2Entries.map { it.sourceId }.toSet()
|
||||||
// Newest two survive; oldest is dropped
|
// 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")),
|
RouterL2Entry(StageId("s3"), summary, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-01T02:00:00Z")),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// "Stage sN (SUCCESS): " + 100x = ~30 tokens each; L0 ~30 tokens.
|
// Size the budget from the measured protected frame + per-entry cost to fit exactly two
|
||||||
// Budget = L0 (30) + two entries (30+30=60) = 90. Third entry doesn't fit → oldest (s1) dropped.
|
// of three entries; the third (oldest, s1) is dropped. Robust to system-prompt length.
|
||||||
val pack = runBlocking { builderTight.build(state, TokenBudget(limit = 90)) }
|
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 l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
|
||||||
val retainedIds = l2Entries.map { it.sourceId }
|
val retainedIds = l2Entries.map { it.sourceId }
|
||||||
// Oldest entry (s1) should be dropped; s2 and s3 retained in chronological order
|
// 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 pack = buildPack(state, TokenBudget(limit = 10000))
|
||||||
val systemEntry = pack.layers[ContextLayer.L0]?.find { it.sourceType == "systemPrompt" }
|
val systemEntry = pack.layers[ContextLayer.L0]?.find { it.sourceType == "systemPrompt" }
|
||||||
assertNotNull(systemEntry)
|
assertNotNull(systemEntry)
|
||||||
assertEquals(
|
val content = systemEntry!!.content
|
||||||
"You are a routing assistant. Provide guidance based on workflow state and conversation context.",
|
assertTrue(content.contains("correx"), "system prompt names the product")
|
||||||
systemEntry!!.content,
|
assertTrue(
|
||||||
|
content.contains("workflow", ignoreCase = true),
|
||||||
|
"system prompt grounds the router in workflow state",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user