feat(router): inject available-workflows and project-profile into router L0 context

This commit is contained in:
2026-06-11 11:11:04 +04:00
parent ac458d83e5
commit a32e9acac4
5 changed files with 114 additions and 25 deletions
@@ -17,11 +17,17 @@ import com.correx.core.router.model.RouterL2Entry
import com.correx.core.router.model.RouterState
import com.correx.core.router.model.RouterTurn
import com.correx.core.router.model.TurnRole
import com.correx.core.router.model.WorkflowSummary
import java.util.*
import kotlinx.coroutines.CancellationException
interface RouterContextBuilder {
suspend fun build(state: RouterState, budget: TokenBudget): ContextPack
suspend fun build(
state: RouterState,
budget: TokenBudget,
availableWorkflows: List<WorkflowSummary> = emptyList(),
projectProfileText: String? = null,
): ContextPack
/**
* Builds a minimal [ContextPack] for a narration inference call.
@@ -32,7 +38,7 @@ interface RouterContextBuilder {
* without needing a stub override.
*/
suspend fun buildNarrationContext(state: RouterState, trigger: NarrationTrigger, budget: TokenBudget): ContextPack =
build(state, budget)
build(state, budget, emptyList(), null)
}
class DefaultRouterContextBuilder(
@@ -68,7 +74,12 @@ class DefaultRouterContextBuilder(
private const val RECALLED_MEMORY_PREFIX = "[recalled memory]"
}
override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack {
override suspend fun build(
state: RouterState,
budget: TokenBudget,
availableWorkflows: List<WorkflowSummary>,
projectProfileText: String?,
): ContextPack {
// Protected frame: system prompt and workflow status are ALWAYS included regardless of budget.
val systemPrompt = buildContextEntry(
sourceType = "systemPrompt",
@@ -84,14 +95,39 @@ class DefaultRouterContextBuilder(
layer = ContextLayer.L0,
role = EntryRole.SYSTEM,
)
val availableWorkflowsEntry: ContextEntry? = availableWorkflows
.takeIf { it.isNotEmpty() }
?.let {
buildContextEntry(
sourceType = "availableWorkflows",
sourceId = "available-workflows",
content = buildAvailableWorkflowsContent(it),
layer = ContextLayer.L0,
role = EntryRole.SYSTEM,
)
}
val projectProfileEntry: ContextEntry? = projectProfileText
?.takeIf { it.isNotBlank() }
?.let {
buildContextEntry(
sourceType = "projectProfile",
sourceId = "project-profile",
content = it,
layer = ContextLayer.L0,
role = EntryRole.SYSTEM,
)
}
// Compute remaining budget after protected frame.
val protectedTokens = systemPrompt.tokenEstimate + workflowStatusEntry.tokenEstimate
val protectedTokens = systemPrompt.tokenEstimate + workflowStatusEntry.tokenEstimate +
(availableWorkflowsEntry?.tokenEstimate ?: 0) + (projectProfileEntry?.tokenEstimate ?: 0)
var remainingBudget = (budget.limit - protectedTokens).coerceAtLeast(0)
val allEntries = mutableListOf<ContextEntry>()
allEntries += systemPrompt
allEntries += workflowStatusEntry
availableWorkflowsEntry?.let { allEntries += it }
projectProfileEntry?.let { allEntries += it }
var droppedCount = 0
val truncatedLayers = mutableSetOf<ContextLayer>()
@@ -258,6 +294,14 @@ class DefaultRouterContextBuilder(
return base + reason + steering
}
private fun buildAvailableWorkflowsContent(workflows: List<WorkflowSummary>): String =
buildString {
append("## Available workflows\n")
workflows.forEach { wf ->
append("- ${wf.id}: ${wf.description} (stages: ${wf.stageIds.joinToString(" → ")})\n")
}
}.trimEnd()
private suspend fun buildContextEntry(
sourceType: String,
sourceId: String,
@@ -0,0 +1,7 @@
package com.correx.core.router.model
data class WorkflowSummary(
val id: String,
val description: String,
val stageIds: List<String>,
)