From 1c027e81899136f28f56ee02ad12d6b751f5f803 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 3 Jul 2026 13:40:00 +0400 Subject: [PATCH] feat(talkie): ground narration in the stage's actual tool actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../core/talkie/TalkieContextBuilder.kt | 33 +++++++--- .../com/correx/core/talkie/TalkieFacade.kt | 29 ++++++++- .../src/test/kotlin/TalkieNarrationTest.kt | 62 ++++++++++++++++++- 3 files changed, 115 insertions(+), 9 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 dc76a70d..21ef3822 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 @@ -38,8 +38,12 @@ interface TalkieContextBuilder { * Default delegates to [build] so that anonymous test stubs that only override [build] compile * without needing a stub override. */ - suspend fun buildNarrationContext(state: TalkieState, trigger: NarrationTrigger, budget: TokenBudget): ContextPack = - build(state, budget, emptyList(), null) + suspend fun buildNarrationContext( + state: TalkieState, + trigger: NarrationTrigger, + budget: TokenBudget, + recentActivity: String? = null, + ): ContextPack = build(state, budget, emptyList(), null) } class DefaultTalkieContextBuilder( @@ -91,12 +95,13 @@ class DefaultTalkieContextBuilder( 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 - 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. + what comes next. Be concrete and grounded in the state you are given — name the stage, + the specific actions taken (the files read or written, tools run), the outcome, and the + reason for any failure or pause. Prefer the concrete actions over restating the stage + 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() private const val RECALLED_MEMORY_PREFIX = "[recalled memory]" @@ -403,6 +408,7 @@ class DefaultTalkieContextBuilder( state: TalkieState, trigger: NarrationTrigger, budget: TokenBudget, + recentActivity: String?, ): ContextPack { val systemPromptEntry = buildContextEntry( sourceType = "systemPrompt", @@ -418,6 +424,17 @@ 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, + ) + } // 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). @@ -431,12 +448,14 @@ class DefaultTalkieContextBuilder( 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 3b27af9f..4054c4d8 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 @@ -8,6 +8,7 @@ import com.correx.core.events.events.IdeaCapturedEvent import com.correx.core.events.events.L3MemoryRetrievedEvent import com.correx.core.events.events.L3RetrievedHit 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.SteeringNoteAddedEvent import com.correx.core.events.events.WorkflowProposedEvent @@ -37,6 +38,10 @@ import java.util.UUID 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 { suspend fun onUserInput( sessionId: SessionId, @@ -206,7 +211,8 @@ class DefaultTalkieFacade( val state = routerRepository.getTalkieState(sessionId) 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 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( sessionId: SessionId, content: String, diff --git a/testing/deterministic/src/test/kotlin/TalkieNarrationTest.kt b/testing/deterministic/src/test/kotlin/TalkieNarrationTest.kt index 598eea54..af6011c0 100644 --- a/testing/deterministic/src/test/kotlin/TalkieNarrationTest.kt +++ b/testing/deterministic/src/test/kotlin/TalkieNarrationTest.kt @@ -6,6 +6,10 @@ import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.NewEvent import com.correx.core.events.events.TalkieNarrationEvent 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.types.ContextPackId import com.correx.core.events.types.EventId @@ -78,6 +82,62 @@ class TalkieNarrationTest { 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) = + 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 fun `narrate does not emit ChatTurnEvent`(): Unit = runBlocking { val eventStore = MapBackedEventStore() @@ -143,7 +203,7 @@ class TalkieNarrationTest { }, routerContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, 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)), eventStore = eventStore,