feat: implement CreateGrant handler and grant-aware approval engine

- GlobalStreamHandler.handleCreateGrant validates scope (SESSION/STAGE)
  and sends ProtocolError to client on validation failure instead of
  silent log.warn + drop.
- Derive event stageId directly from GrantScope type, removing
  redundant stageIdForEvent tuple.
- SessionOrchestrator integrates ApprovalEngine to check active grants
  before requesting user approval for T2+ tool calls.
- ContextEntry gains EntryRole (SYSTEM/USER/ASSISTANT/TOOL) field for
  proper chat message role mapping.
- RouterContextBuilder.build is now suspend; uses Tokenizer for
  accurate token estimation with fallback to character-based estimate.
- LlamaCppInferenceProvider maps EntryRole to ChatMessage role instead
  of heuristic layer-based inference.
This commit is contained in:
2026-05-28 14:06:58 +04:00
parent 92bea6c2c4
commit e05532e7b2
11 changed files with 316 additions and 133 deletions
@@ -4,23 +4,27 @@ import com.correx.core.context.model.CompressionMetadata
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.ContextPack
import com.correx.core.context.model.EntryRole
import com.correx.core.context.model.TokenBudget
import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.Tokenizer
import com.correx.core.router.model.RouterConfig
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 java.util.*
interface RouterContextBuilder {
fun build(state: RouterState, budget: TokenBudget): ContextPack
suspend fun build(state: RouterState, budget: TokenBudget): ContextPack
}
class DefaultRouterContextBuilder(
private val config: RouterConfig,
private val tokenizer: Tokenizer? = null,
) : RouterContextBuilder {
companion object {
@@ -28,40 +32,43 @@ class DefaultRouterContextBuilder(
"You are a routing assistant. Provide guidance based on workflow state and conversation context."
}
override fun build(state: RouterState, budget: TokenBudget): ContextPack {
override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack {
var remainingBudget = budget.limit
val allEntries = mutableListOf<ContextEntry>()
var droppedCount = 0
// === L0: System prompt (never dropped) ===
val systemPrompt = buildContextEntry(
sourceType = "systemPrompt",
sourceId = "router-system",
content = SYSTEM_PROMPT,
role = EntryRole.SYSTEM,
)
remainingBudget -= systemPrompt.tokenEstimate
if (remainingBudget < 0) remainingBudget = 0
allEntries += systemPrompt
// === L0: Workflow status (never dropped) ===
val workflowStatusEntry = buildContextEntry(
sourceType = "workflowStatus",
sourceId = state.currentStageId?.value ?: "none",
content = buildWorkflowStatusContent(state),
role = EntryRole.SYSTEM,
)
remainingBudget -= workflowStatusEntry.tokenEstimate
if (remainingBudget < 0) remainingBudget = 0
allEntries += workflowStatusEntry
// === L1: Conversation history (last N turns, capped at keepLast) ===
val recentTurns = state.conversationHistory.takeLast(config.conversationKeepLast)
for (turn in recentTurns) {
val content = buildConversationContent(turn)
val role = when (turn.role) {
TurnRole.USER -> EntryRole.USER
TurnRole.ROUTER -> EntryRole.ASSISTANT
}
val entry = buildContextEntry(
sourceType = "conversation",
sourceId = "${turn.role.name}-${turn.hashCode()}",
content = content,
content = turn.content,
layer = ContextLayer.L1,
role = role,
)
if (remainingBudget >= entry.tokenEstimate) {
remainingBudget -= entry.tokenEstimate
@@ -72,7 +79,6 @@ class DefaultRouterContextBuilder(
}
}
// === L2: Stage summaries from L2 memory (oldest-first eviction) ===
for (l2Entry in state.l2Memory) {
val content = buildL2Content(l2Entry)
val entry = buildContextEntry(
@@ -80,6 +86,7 @@ class DefaultRouterContextBuilder(
sourceId = l2Entry.stageId.value,
content = content,
layer = ContextLayer.L2,
role = EntryRole.SYSTEM,
)
if (remainingBudget >= entry.tokenEstimate) {
remainingBudget -= entry.tokenEstimate
@@ -90,7 +97,6 @@ class DefaultRouterContextBuilder(
}
}
// Assemble layers and pack
val layers = allEntries.groupBy { it.layer }
val budgetUsed = allEntries.sumOf { it.tokenEstimate }
@@ -119,19 +125,16 @@ class DefaultRouterContextBuilder(
return s.lowercase().let { it[0].uppercaseChar() + it.drop(1) }
}
private fun buildConversationContent(turn: RouterTurn): String {
return "[${turn.role.name}] ${turn.content}"
}
private fun buildL2Content(l2Entry: RouterL2Entry): String {
return "Stage ${l2Entry.stageId.value} (${l2Entry.outcome}): ${l2Entry.summary}"
}
private fun buildContextEntry(
private suspend fun buildContextEntry(
sourceType: String,
sourceId: String,
content: String,
layer: ContextLayer = ContextLayer.L0,
role: EntryRole = EntryRole.USER,
): ContextEntry {
return ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
@@ -140,10 +143,19 @@ class DefaultRouterContextBuilder(
sourceType = sourceType,
sourceId = sourceId,
tokenEstimate = estimateTokens(content),
role = role,
)
}
private fun estimateTokens(content: String): Int {
return (content.length / 2).coerceAtLeast(1)
private suspend fun estimateTokens(content: String): Int {
val t = tokenizer
if (t != null) {
return runCatching { t.countTokens(content) }.getOrElse { fallbackEstimate(content) }
}
return fallbackEstimate(content)
}
}
private fun fallbackEstimate(content: String): Int {
return (content.length / 4).coerceAtLeast(1)
}
}