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,
|
||||
|
||||
Reference in New Issue
Block a user