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:
@@ -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<ContextEntry>()
|
||||
allEntries += systemPromptEntry
|
||||
allEntries += workflowStatusEntry
|
||||
activityEntry?.let { allEntries += it }
|
||||
|
||||
var droppedCount = 0
|
||||
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.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,
|
||||
|
||||
@@ -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<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
|
||||
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<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)),
|
||||
eventStore = eventStore,
|
||||
|
||||
Reference in New Issue
Block a user