fix(talkie): surface narration actions in the user turn, session-wide

Live run showed the actions were in the prompt but the 1.2B narrator ignored
them: they sat in a system-role entry (small models under-weight system context)
and were stage-scoped to the pausing stage, which usually isn't the one that ran
the tools. Fold the recent actions into the USER trigger instruction instead, and
take the last N tool calls across the whole run rather than per-stage. Narration
now names the concrete actions ("reviewing server directories and reading the
specified file") instead of generic filler.
This commit is contained in:
2026-07-03 14:03:24 +04:00
parent 1c027e8189
commit bc5fedf99b
2 changed files with 13 additions and 21 deletions
@@ -424,38 +424,30 @@ 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 // Concrete actions taken (tool calls), folded into the USER instruction below rather than a
// happened instead of a generic "stage completed". Null/blank when nothing ran yet. // separate system entry: small narrator models attend to the user turn far more than system
val activityEntry = recentActivity?.takeIf { it.isNotBlank() }?.let { // context, so this is what actually makes the narration name what happened instead of a
buildContextEntry( // generic "stage completed". Null/blank when nothing ran yet.
sourceType = "stageActivity", val activityLine = recentActivity?.takeIf { it.isNotBlank() }
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).
val triggerEntry = buildContextEntry( val triggerEntry = buildContextEntry(
sourceType = "narrationTrigger", sourceType = "narrationTrigger",
sourceId = trigger.kind, sourceId = trigger.kind,
content = trigger.instruction, content = activityLine?.let { "${trigger.instruction}\n\n$it" } ?: trigger.instruction,
layer = ContextLayer.L1, layer = ContextLayer.L1,
role = EntryRole.USER, role = EntryRole.USER,
) )
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>()
@@ -211,7 +211,7 @@ 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 recentActivity = recentStageActivity(sessionId, effectiveStageId) val recentActivity = recentActivity(sessionId)
val contextPack = routerContextBuilder.buildNarrationContext(state, trigger, config.tokenBudget, recentActivity) 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)
@@ -255,15 +255,15 @@ class DefaultTalkieFacade(
} }
/** /**
* Concrete tool actions taken during [stageId], read straight from the event log so the * The most recent concrete tool actions in the session, read straight from the event log so
* narrator can say what actually happened (files read/written, tools run) instead of a * 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. * generic "stage completed". Not stage-scoped: a narration commonly fires from a later stage
* (e.g. an approval pause) than the one that did the work, so the recent actions across the
* whole run are what "just happened". Returns null when nothing ran; capped at the last few.
*/ */
private suspend fun recentStageActivity(sessionId: SessionId, stageId: StageId): String? { private suspend fun recentActivity(sessionId: SessionId): String? {
if (stageId == StageId.NONE) return null
val actions = eventStore.read(sessionId) val actions = eventStore.read(sessionId)
.mapNotNull { it.payload as? ToolInvocationRequestedEvent } .mapNotNull { it.payload as? ToolInvocationRequestedEvent }
.filter { it.stageId == stageId }
.takeLast(MAX_NARRATED_ACTIONS) .takeLast(MAX_NARRATED_ACTIONS)
.map { req -> .map { req ->
val params = req.request.parameters val params = req.request.parameters