From bc5fedf99bd2e3d8443f7ed3d312b569be373986 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 3 Jul 2026 14:03:24 +0400 Subject: [PATCH] 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. --- .../core/talkie/TalkieContextBuilder.kt | 20 ++++++------------- .../com/correx/core/talkie/TalkieFacade.kt | 14 ++++++------- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/core/talkie/src/main/kotlin/com/correx/core/talkie/TalkieContextBuilder.kt b/core/talkie/src/main/kotlin/com/correx/core/talkie/TalkieContextBuilder.kt index 21ef3822..d1d61045 100644 --- a/core/talkie/src/main/kotlin/com/correx/core/talkie/TalkieContextBuilder.kt +++ b/core/talkie/src/main/kotlin/com/correx/core/talkie/TalkieContextBuilder.kt @@ -424,38 +424,30 @@ class DefaultTalkieContextBuilder( layer = ContextLayer.L0, 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, - ) - } + // Concrete actions taken (tool calls), folded into the USER instruction below rather than a + // separate system entry: small narrator models attend to the user turn far more than system + // context, so this is what actually makes the narration name what happened instead of a + // generic "stage completed". Null/blank when nothing ran yet. + val activityLine = recentActivity?.takeIf { it.isNotBlank() } // 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 // request would go out with no user message (the chat template then rejects it). val triggerEntry = buildContextEntry( sourceType = "narrationTrigger", sourceId = trigger.kind, - content = trigger.instruction, + content = activityLine?.let { "${trigger.instruction}\n\n$it" } ?: trigger.instruction, layer = ContextLayer.L1, role = EntryRole.USER, ) val protectedTokens = systemPromptEntry.tokenEstimate + workflowStatusEntry.tokenEstimate + - (activityEntry?.tokenEstimate ?: 0) + triggerEntry.tokenEstimate var remainingBudget = (budget.limit - protectedTokens).coerceAtLeast(0) val allEntries = mutableListOf() allEntries += systemPromptEntry allEntries += workflowStatusEntry - activityEntry?.let { allEntries += it } var droppedCount = 0 val truncatedLayers = mutableSetOf() diff --git a/core/talkie/src/main/kotlin/com/correx/core/talkie/TalkieFacade.kt b/core/talkie/src/main/kotlin/com/correx/core/talkie/TalkieFacade.kt index 4054c4d8..832f2799 100644 --- a/core/talkie/src/main/kotlin/com/correx/core/talkie/TalkieFacade.kt +++ b/core/talkie/src/main/kotlin/com/correx/core/talkie/TalkieFacade.kt @@ -211,7 +211,7 @@ class DefaultTalkieFacade( val state = routerRepository.getTalkieState(sessionId) 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 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 - * 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. + * The most recent concrete tool actions in the session, 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". 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? { - if (stageId == StageId.NONE) return null + private suspend fun recentActivity(sessionId: SessionId): String? { 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