diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 8283d69c..96090258 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -1542,7 +1542,13 @@ abstract class SessionOrchestrator( protected open suspend fun estimateTokens(content: String): Int { val t = tokenizer if (t != null) { - return runCatching { t.countTokens(content) }.getOrElse { fallbackTokenEstimate(content) } + // A zero count for non-blank content means the tokenizer endpoint is lying + // (e.g. a malformed request silently answered with an empty token list) — + // trust the heuristic instead, or every budget check goes blind. + return runCatching { t.countTokens(content) } + .getOrElse { fallbackTokenEstimate(content) } + .takeIf { it > 0 || content.isBlank() } + ?: fallbackTokenEstimate(content) } return fallbackTokenEstimate(content) } diff --git a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppApiModels.kt b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppApiModels.kt index b970ee38..12821e0c 100644 --- a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppApiModels.kt +++ b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppApiModels.kt @@ -49,7 +49,7 @@ data class Usage( ) @Serializable -data class TokenizeRequest(val tokens: List) +data class TokenizeRequest(val content: String) @Serializable data class TokenizeResponse(val tokens: List) diff --git a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppTokenizer.kt b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppTokenizer.kt index c1ff9d1d..11daabee 100644 --- a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppTokenizer.kt +++ b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppTokenizer.kt @@ -19,7 +19,7 @@ class LlamaCppTokenizer( httpClient.post("$baseUrl/tokenize") { contentType(ContentType.Application.Json) accept(ContentType.Application.Json) - setBody(TokenizeRequest(tokens = listOf(text))) + setBody(TokenizeRequest(content = text)) }.body().tokens.map { Token(it) } override suspend fun countTokens(text: String): Int = tokenize(text).size diff --git a/testing/deterministic/src/test/kotlin/DefaultContextPackBuilderTest.kt b/testing/deterministic/src/test/kotlin/DefaultContextPackBuilderTest.kt index 4f867479..5d7aac25 100644 --- a/testing/deterministic/src/test/kotlin/DefaultContextPackBuilderTest.kt +++ b/testing/deterministic/src/test/kotlin/DefaultContextPackBuilderTest.kt @@ -31,6 +31,36 @@ class DefaultContextPackBuilderTest { tokenEstimate = tokens ) + private fun typedEntry(id: String, layer: ContextLayer, tokens: Int, sourceType: String) = ContextEntry( + id = ContextEntryId(id), + layer = layer, + content = "content-$id", + sourceType = sourceType, + sourceId = id, + tokenEstimate = tokens + ) + + @Test + fun `oversized tool results from a stage tool loop are trimmed to budget`() { + // Live repro (2026-06-11): analyst stage with three file_read results of ~5.6k/11.9k/10.7k + // tokens sailed through a 16384 budget untrimmed and produced a 34k-token prompt. + val entries = listOf( + typedEntry("sys", ContextLayer.L0, 3500, "systemPrompt"), + typedEntry("task", ContextLayer.L1, 150, "agentPrompt"), + typedEntry("call1", ContextLayer.L2, 40, "assistantToolCall"), + typedEntry("res1", ContextLayer.L2, 5600, "toolResult"), + typedEntry("call2", ContextLayer.L2, 40, "assistantToolCall"), + typedEntry("res2", ContextLayer.L2, 11900, "toolResult"), + typedEntry("call3", ContextLayer.L2, 40, "assistantToolCall"), + typedEntry("res3", ContextLayer.L2, 10700, "toolResult"), + ) + val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 16384)) + assertTrue( + pack.budgetUsed <= 16384, + "budgetUsed ${pack.budgetUsed} must not exceed the 16384 stage budget", + ) + } + @Test fun `build groups entries by layer`() { val entries = listOf(