feat(talkie): ground narration in the stage's actual tool actions

Narration was generic ("Stage X completed. Summarise.") because nothing about
what the stage actually did reached the narrator — the L2 summary is a
placeholder and StageCompletedEvent carries no output. narrate() now reads the
stage's ToolInvocationRequested events from the log and feeds the narrator a
concrete 'Actions taken this stage: file_read(path), ...' entry, and the system
prompt asks it to prefer those concrete actions over restating the stage name.
This commit is contained in:
2026-07-03 13:40:00 +04:00
parent 0c8a7e88f6
commit 1c027e8189
3 changed files with 115 additions and 9 deletions
@@ -38,8 +38,12 @@ interface TalkieContextBuilder {
* Default delegates to [build] so that anonymous test stubs that only override [build] compile * Default delegates to [build] so that anonymous test stubs that only override [build] compile
* without needing a stub override. * without needing a stub override.
*/ */
suspend fun buildNarrationContext(state: TalkieState, trigger: NarrationTrigger, budget: TokenBudget): ContextPack = suspend fun buildNarrationContext(
build(state, budget, emptyList(), null) state: TalkieState,
trigger: NarrationTrigger,
budget: TokenBudget,
recentActivity: String? = null,
): ContextPack = build(state, budget, emptyList(), null)
} }
class DefaultTalkieContextBuilder( class DefaultTalkieContextBuilder(
@@ -91,12 +95,13 @@ class DefaultTalkieContextBuilder(
private val NARRATION_SYSTEM_PROMPT = private val NARRATION_SYSTEM_PROMPT =
""" """
You are correx's router, narrating a running workflow to the operator in their live You are correx's narrator, describing 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 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 what comes next. Be concrete and grounded in the state you are given — name the stage,
the stage, the outcome, and the reason for any failure or pause. Do not use lists, the specific actions taken (the files read or written, tools run), the outcome, and the
headings, or preamble, and do not invent details you were not given. Speak in the reason for any failure or pause. Prefer the concrete actions over restating the stage
present, as events unfold. name. Do not use lists, headings, or preamble, and do not invent details you were not
given. Speak in the present, as events unfold.
""".trimIndent() """.trimIndent()
private const val RECALLED_MEMORY_PREFIX = "[recalled memory]" private const val RECALLED_MEMORY_PREFIX = "[recalled memory]"
@@ -403,6 +408,7 @@ class DefaultTalkieContextBuilder(
state: TalkieState, state: TalkieState,
trigger: NarrationTrigger, trigger: NarrationTrigger,
budget: TokenBudget, budget: TokenBudget,
recentActivity: String?,
): ContextPack { ): ContextPack {
val systemPromptEntry = buildContextEntry( val systemPromptEntry = buildContextEntry(
sourceType = "systemPrompt", sourceType = "systemPrompt",
@@ -418,6 +424,17 @@ class DefaultTalkieContextBuilder(
layer = ContextLayer.L0, layer = ContextLayer.L0,
role = EntryRole.SYSTEM, role = EntryRole.SYSTEM,
) )
// Concrete actions the stage took (tool calls), so the narrator names what actually
// happened instead of a generic "stage completed". Null/blank when nothing ran yet.
val activityEntry = recentActivity?.takeIf { it.isNotBlank() }?.let {
buildContextEntry(
sourceType = "stageActivity",
sourceId = trigger.stageId ?: state.currentStageId?.value ?: "none",
content = it,
layer = ContextLayer.L0,
role = EntryRole.SYSTEM,
)
}
// L1, not L0: PromptRenderer folds every L0 entry into the single system message // L1, not L0: PromptRenderer folds every L0 entry into the single system message
// regardless of role, so an L0 user turn would vanish into the system block and the // regardless of role, so an L0 user turn would vanish into the system block and the
// request would go out with no user message (the chat template then rejects it). // request would go out with no user message (the chat template then rejects it).
@@ -431,12 +448,14 @@ class DefaultTalkieContextBuilder(
val protectedTokens = systemPromptEntry.tokenEstimate + val protectedTokens = systemPromptEntry.tokenEstimate +
workflowStatusEntry.tokenEstimate + workflowStatusEntry.tokenEstimate +
(activityEntry?.tokenEstimate ?: 0) +
triggerEntry.tokenEstimate triggerEntry.tokenEstimate
var remainingBudget = (budget.limit - protectedTokens).coerceAtLeast(0) var remainingBudget = (budget.limit - protectedTokens).coerceAtLeast(0)
val allEntries = mutableListOf<ContextEntry>() val allEntries = mutableListOf<ContextEntry>()
allEntries += systemPromptEntry allEntries += systemPromptEntry
allEntries += workflowStatusEntry allEntries += workflowStatusEntry
activityEntry?.let { allEntries += it }
var droppedCount = 0 var droppedCount = 0
val truncatedLayers = mutableSetOf<ContextLayer>() val truncatedLayers = mutableSetOf<ContextLayer>()
@@ -8,6 +8,7 @@ import com.correx.core.events.events.IdeaCapturedEvent
import com.correx.core.events.events.L3MemoryRetrievedEvent import com.correx.core.events.events.L3MemoryRetrievedEvent
import com.correx.core.events.events.L3RetrievedHit import com.correx.core.events.events.L3RetrievedHit
import com.correx.core.events.events.NewEvent import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.TalkieNarrationEvent import com.correx.core.events.events.TalkieNarrationEvent
import com.correx.core.events.events.SteeringNoteAddedEvent import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.WorkflowProposedEvent import com.correx.core.events.events.WorkflowProposedEvent
@@ -37,6 +38,10 @@ import java.util.UUID
private val log = LoggerFactory.getLogger(DefaultTalkieFacade::class.java) private val log = LoggerFactory.getLogger(DefaultTalkieFacade::class.java)
// Narration activity caps: keep the "what happened" line short and cheap.
private const val MAX_NARRATED_ACTIONS = 6
private const val ACTION_ARG_MAX = 60
interface TalkieFacade { interface TalkieFacade {
suspend fun onUserInput( suspend fun onUserInput(
sessionId: SessionId, sessionId: SessionId,
@@ -206,7 +211,8 @@ class DefaultTalkieFacade(
val state = routerRepository.getTalkieState(sessionId) val state = routerRepository.getTalkieState(sessionId)
val effectiveStageId = trigger.stageId?.let { StageId(it) } ?: state.currentStageId ?: StageId.NONE val effectiveStageId = trigger.stageId?.let { StageId(it) } ?: state.currentStageId ?: StageId.NONE
val contextPack = routerContextBuilder.buildNarrationContext(state, trigger, config.tokenBudget) val recentActivity = recentStageActivity(sessionId, effectiveStageId)
val contextPack = routerContextBuilder.buildNarrationContext(state, trigger, config.tokenBudget, recentActivity)
val provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General), config.narrationModelId) val provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General), config.narrationModelId)
val inferenceRequest = InferenceRequest( val inferenceRequest = InferenceRequest(
@@ -248,6 +254,27 @@ class DefaultTalkieFacade(
) )
} }
/**
* Concrete tool actions taken during [stageId], read straight from the event log so the
* narrator can say what actually happened (files read/written, tools run) instead of a
* generic "stage completed". Returns null when nothing ran. Capped at the last few calls.
*/
private suspend fun recentStageActivity(sessionId: SessionId, stageId: StageId): String? {
if (stageId == StageId.NONE) return null
val actions = eventStore.read(sessionId)
.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
.filter { it.stageId == stageId }
.takeLast(MAX_NARRATED_ACTIONS)
.map { req ->
val params = req.request.parameters
val arg = (params["path"] ?: params["file"] ?: params["command"] ?: params["query"]
?: params.values.firstOrNull())?.toString()?.take(ACTION_ARG_MAX)
if (arg.isNullOrBlank()) req.toolName else "${req.toolName}($arg)"
}
if (actions.isEmpty()) return null
return "Actions taken this stage: " + actions.joinToString(", ")
}
private suspend fun emitChatTurn( private suspend fun emitChatTurn(
sessionId: SessionId, sessionId: SessionId,
content: String, content: String,
@@ -6,6 +6,10 @@ import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.TalkieNarrationEvent import com.correx.core.events.events.TalkieNarrationEvent
import com.correx.core.events.events.StoredEvent import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.approvals.Tier
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ContextPackId import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.EventId import com.correx.core.events.types.EventId
@@ -78,6 +82,62 @@ class TalkieNarrationTest {
assertEquals("stage_completed", event.trigger) assertEquals("stage_completed", event.trigger)
} }
@Test
fun `narrate context carries the stage's concrete tool actions`(): Unit = runBlocking {
val eventStore = MapBackedEventStore()
val stage = StageId("s1") // matches buildFacade's currentStageId
val session = SessionId("sess-activity")
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId("e1"), sessionId = session,
timestamp = kotlinx.datetime.Clock.System.now(), schemaVersion = 1,
causationId = null, correlationId = null,
),
payload = ToolInvocationRequestedEvent(
invocationId = ToolInvocationId("inv-1"), sessionId = session, stageId = stage,
toolName = "file_read", tier = Tier.T1,
request = ToolRequest(
invocationId = ToolInvocationId("inv-1"), sessionId = session, stageId = stage,
toolName = "file_read", parameters = mapOf("path" to "apps/server/ServerModule.kt"),
),
),
),
)
var captured: InferenceRequest? = null
val facade = DefaultTalkieFacade(
routerRepository = object : TalkieRepository {
override suspend fun getTalkieState(sessionId: SessionId) =
TalkieState(sessionId = sessionId, workflowStatus = WorkflowStatus.RUNNING, currentStageId = stage)
},
routerContextBuilder = DefaultTalkieContextBuilder(TalkieConfig()),
inferenceRouter = object : InferenceRouter {
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>) =
object : InferenceProvider {
override val id = ProviderId("mock"); override val name = "Mock"
override val tokenizer = MockTokenizer()
override suspend fun infer(request: InferenceRequest): InferenceResponse {
captured = request
return InferenceResponse(request.requestId, "line", FinishReason.Stop, TokenUsage(1, 1), 1L)
}
override suspend fun healthCheck() = ProviderHealth.Healthy
override fun capabilities() = setOf(CapabilityScore(ModelCapability.General, 1.0))
}
},
eventStore = eventStore,
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)),
embedder = NoopEmbedder(dimension = 8),
l3MemoryStore = InMemoryL3MemoryStore(),
)
facade.narrate(session, NarrationTrigger(kind = "stage_completed", instruction = "Summarise.", stageId = "s1"))
val contents = captured!!.contextPack.layers.values.flatten().map { it.content }
val activity = contents.firstOrNull { "file_read" in it }
assertNotNull(activity, "narration context must include the stage's tool actions; got: $contents")
assertTrue("apps/server/ServerModule.kt" in activity!!, "activity must name the file: $activity")
}
@Test @Test
fun `narrate does not emit ChatTurnEvent`(): Unit = runBlocking { fun `narrate does not emit ChatTurnEvent`(): Unit = runBlocking {
val eventStore = MapBackedEventStore() val eventStore = MapBackedEventStore()
@@ -143,7 +203,7 @@ class TalkieNarrationTest {
}, },
routerContextBuilder = object : TalkieContextBuilder { routerContextBuilder = object : TalkieContextBuilder {
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack() override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack()
override suspend fun buildNarrationContext(state: TalkieState, trigger: NarrationTrigger, budget: TokenBudget): ContextPack = emptyContextPack() override suspend fun buildNarrationContext(state: TalkieState, trigger: NarrationTrigger, budget: TokenBudget, recentActivity: String?): ContextPack = emptyContextPack()
}, },
inferenceRouter = mockInferenceRouter("stage narration", latencyMs = 10L, tokensUsed = TokenUsage(1, 1)), inferenceRouter = mockInferenceRouter("stage narration", latencyMs = 10L, tokensUsed = TokenUsage(1, 1)),
eventStore = eventStore, eventStore = eventStore,