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
|
* Default delegates to [build] so that anonymous test stubs that only override [build] compile
|
||||||
* without needing a stub override.
|
* without needing a stub override.
|
||||||
*/
|
*/
|
||||||
suspend fun buildNarrationContext(state: TalkieState, trigger: NarrationTrigger, budget: TokenBudget): ContextPack =
|
suspend fun buildNarrationContext(
|
||||||
build(state, budget, emptyList(), null)
|
state: TalkieState,
|
||||||
|
trigger: NarrationTrigger,
|
||||||
|
budget: TokenBudget,
|
||||||
|
recentActivity: String? = null,
|
||||||
|
): ContextPack = build(state, budget, emptyList(), null)
|
||||||
}
|
}
|
||||||
|
|
||||||
class DefaultTalkieContextBuilder(
|
class DefaultTalkieContextBuilder(
|
||||||
@@ -91,12 +95,13 @@ class DefaultTalkieContextBuilder(
|
|||||||
|
|
||||||
private val NARRATION_SYSTEM_PROMPT =
|
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
|
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
|
what comes next. Be concrete and grounded in the state you are given — name the stage,
|
||||||
the stage, the outcome, and the reason for any failure or pause. Do not use lists,
|
the specific actions taken (the files read or written, tools run), the outcome, and the
|
||||||
headings, or preamble, and do not invent details you were not given. Speak in the
|
reason for any failure or pause. Prefer the concrete actions over restating the stage
|
||||||
present, as events unfold.
|
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()
|
""".trimIndent()
|
||||||
|
|
||||||
private const val RECALLED_MEMORY_PREFIX = "[recalled memory]"
|
private const val RECALLED_MEMORY_PREFIX = "[recalled memory]"
|
||||||
@@ -403,6 +408,7 @@ class DefaultTalkieContextBuilder(
|
|||||||
state: TalkieState,
|
state: TalkieState,
|
||||||
trigger: NarrationTrigger,
|
trigger: NarrationTrigger,
|
||||||
budget: TokenBudget,
|
budget: TokenBudget,
|
||||||
|
recentActivity: String?,
|
||||||
): ContextPack {
|
): ContextPack {
|
||||||
val systemPromptEntry = buildContextEntry(
|
val systemPromptEntry = buildContextEntry(
|
||||||
sourceType = "systemPrompt",
|
sourceType = "systemPrompt",
|
||||||
@@ -418,6 +424,17 @@ 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
|
||||||
|
// 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
|
// 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).
|
||||||
@@ -431,12 +448,14 @@ class DefaultTalkieContextBuilder(
|
|||||||
|
|
||||||
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>()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.correx.core.events.events.IdeaCapturedEvent
|
|||||||
import com.correx.core.events.events.L3MemoryRetrievedEvent
|
import com.correx.core.events.events.L3MemoryRetrievedEvent
|
||||||
import com.correx.core.events.events.L3RetrievedHit
|
import com.correx.core.events.events.L3RetrievedHit
|
||||||
import com.correx.core.events.events.NewEvent
|
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.TalkieNarrationEvent
|
||||||
import com.correx.core.events.events.SteeringNoteAddedEvent
|
import com.correx.core.events.events.SteeringNoteAddedEvent
|
||||||
import com.correx.core.events.events.WorkflowProposedEvent
|
import com.correx.core.events.events.WorkflowProposedEvent
|
||||||
@@ -37,6 +38,10 @@ import java.util.UUID
|
|||||||
|
|
||||||
private val log = LoggerFactory.getLogger(DefaultTalkieFacade::class.java)
|
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 {
|
interface TalkieFacade {
|
||||||
suspend fun onUserInput(
|
suspend fun onUserInput(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
@@ -206,7 +211,8 @@ 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 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 provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General), config.narrationModelId)
|
||||||
val inferenceRequest = InferenceRequest(
|
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(
|
private suspend fun emitChatTurn(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
content: String,
|
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.NewEvent
|
||||||
import com.correx.core.events.events.TalkieNarrationEvent
|
import com.correx.core.events.events.TalkieNarrationEvent
|
||||||
import com.correx.core.events.events.StoredEvent
|
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.stores.EventStore
|
||||||
import com.correx.core.events.types.ContextPackId
|
import com.correx.core.events.types.ContextPackId
|
||||||
import com.correx.core.events.types.EventId
|
import com.correx.core.events.types.EventId
|
||||||
@@ -78,6 +82,62 @@ class TalkieNarrationTest {
|
|||||||
assertEquals("stage_completed", event.trigger)
|
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
|
@Test
|
||||||
fun `narrate does not emit ChatTurnEvent`(): Unit = runBlocking {
|
fun `narrate does not emit ChatTurnEvent`(): Unit = runBlocking {
|
||||||
val eventStore = MapBackedEventStore()
|
val eventStore = MapBackedEventStore()
|
||||||
@@ -143,7 +203,7 @@ class TalkieNarrationTest {
|
|||||||
},
|
},
|
||||||
routerContextBuilder = object : TalkieContextBuilder {
|
routerContextBuilder = object : TalkieContextBuilder {
|
||||||
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack()
|
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)),
|
inferenceRouter = mockInferenceRouter("stage narration", latencyMs = 10L, tokensUsed = TokenUsage(1, 1)),
|
||||||
eventStore = eventStore,
|
eventStore = eventStore,
|
||||||
|
|||||||
Reference in New Issue
Block a user