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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user