Skip to content

StatelessHistory.kt

src/jvmMain/kotlin/afield/ai/models/StatelessHistory.kt · 140 lines

package afield.ai.models

import com.openai.models.responses.ResponseInputItem

Keeping a stateless provider's conversation (local models, OpenRouter) inside its context window.

A stateless provider is sent its whole conversation on every request, so the conversation is ours to size, and an item count does not size it: every turn's user message carries the full instructions, and each of those copies stays in history. Three things keep it inside:

  • settle, once per turn boundary: the finished turn's prompt is swapped for its durable text (the request without the instructions, which the newest message always carries), and older tool outputs and images are cut down. Done once, so the history stays a stable prefix for the server's prompt cache.
  • dropOldestTurns, when an estimate says the next request will not fit: whole turns go from the front, always at a user message, so no function call loses its output.
  • contextOverflow reads the window out of the server's own refusal, in llama.cpp's wording ("request (65833 tokens) exceeds the available context size (65536 tokens)") or vLLM's, which calibrates the estimate; the bridge then fits the request and retries.

internal object StatelessHistory

Sizing a stateless provider's conversation: what each item costs, and what to settle or drop.

internal object StatelessHistory {

const val IMAGE_CHARS = 4_500

An image is counted as this many characters (~1,500 tokens), whatever its data URL's length.

const val SETTLED_OUTPUT_CHARS = 1_500

Settled tool outputs longer than this are cut to SETTLED_OUTPUT_HEAD plus a note.

const val SETTLED_OUTPUT_HEAD = 1_200

const val DEFAULT_CHARS_PER_TOKEN = 3.0

Until a response reports its usage: a conservative ratio for code- and JSON-heavy input.

const val DROPPED_NOTE

    const val DROPPED_NOTE = "[Earlier turns of this conversation were dropped to fit the model's context window. " +
        "Read boxes again with tools rather than relying on memory of them.]"

fun chars(item: ResponseInputItem): Int

    fun chars(item: ResponseInputItem): Int = when {
        item.isMessage() -> item.asMessage().content().sumOf { c ->
            when {
                c.isInputText() -> c.asInputText().text().length
                c.isInputImage() -> IMAGE_CHARS
                else -> c.toString().length
            }
        }
        item.isFunctionCallOutput() -> item.asFunctionCallOutput().output().let { if (it.isString()) it.asString().length else it.toString().length }
        else -> item.toString().length
    }

fun chars(items: List<ResponseInputItem>): Long = items.sumOf { chars(it).toLong() }

fun isUserMessage(item: ResponseInputItem): Boolean

    fun isUserMessage(item: ResponseInputItem): Boolean = when {
        item.isMessage() -> item.asMessage().role() == ResponseInputItem.Message.Role.USER
        item.isEasyInputMessage() -> item.asEasyInputMessage().role().toString().equals("user", ignoreCase = true)
        else -> false
    }

    private fun isDroppedNote(item: ResponseInputItem): Boolean =
        item.isMessage() && item.asMessage().content().singleOrNull()?.inputText()?.orElse(null)?.text() == DROPPED_NOTE

fun userText(text: String): ResponseInputItem

    fun userText(text: String): ResponseInputItem = ResponseInputItem.ofMessage(
        ResponseInputItem.Message.builder().role(ResponseInputItem.Message.Role.USER).addInputTextContent(text).build()
    )

fun shrunkOutput(item: ResponseInputItem): ResponseInputItem?

item with a long function output cut to its head, or null when it is already short enough.

    fun shrunkOutput(item: ResponseInputItem): ResponseInputItem? {
        if (!item.isFunctionCallOutput()) return null
        val output = item.asFunctionCallOutput()
        val text = output.output().takeIf { it.isString() }?.asString() ?: return null
        if (text.length <= SETTLED_OUTPUT_CHARS) return null
        val cut = text.take(SETTLED_OUTPUT_HEAD) +
            "…[${text.length - SETTLED_OUTPUT_HEAD} more chars of this earlier tool result were trimmed to save context; call the tool again if you need them]"
        return ResponseInputItem.ofFunctionCallOutput(
            ResponseInputItem.FunctionCallOutput.builder().callId(output.callId()).output(cut).build()
        )
    }

fun withoutImages(item: ResponseInputItem): ResponseInputItem?

item without its images, or null when it has none.

    fun withoutImages(item: ResponseInputItem): ResponseInputItem? {
        if (!item.isMessage()) return null
        val message = item.asMessage()
        val images = message.content().count { it.isInputImage() }
        if (images == 0) return null
        val builder = ResponseInputItem.Message.builder().role(message.role())
        message.content().filter { it.isInputText() }.forEach { builder.addInputTextContent(it.asInputText().text()) }
        builder.addInputTextContent("[$images image(s) from an earlier turn omitted to save context.]")
        return ResponseInputItem.ofMessage(builder.build())
    }

fun settle(history: MutableList<ResponseInputItem>, from: Int, until: Int = history.size,

Shrink history[from, until) in place: replace first (the finished turn's full prompt for its durable one, by identity), then long outputs and images.

    fun settle(history: MutableList<ResponseInputItem>, from: Int, until: Int = history.size,
               replace: Pair<ResponseInputItem, ResponseInputItem>? = null) {
        for (i in from until until.coerceAtMost(history.size)) {
            var item = history[i]
            if (replace != null && item === replace.first) item = replace.second
            item = shrunkOutput(item) ?: item
            item = withoutImages(item) ?: item
            history[i] = item
        }
    }

fun dropOldestTurns(history: MutableList<ResponseInputItem>, seedCount: Int, protectFrom: Int, over: () -> Boolean): Int {

Remove whole turns after the first seedCount items while over says the history is too big. A turn ends where the next user message starts, so every removed call goes with its output. Nothing at or after protectFrom (the current turn) is removed. Returns the number of items removed.

    fun dropOldestTurns(history: MutableList<ResponseInputItem>, seedCount: Int, protectFrom: Int, over: () -> Boolean): Int {
        var protect = protectFrom.coerceAtMost(history.size)
        val start = if (history.size > seedCount && isDroppedNote(history[seedCount])) seedCount + 1 else seedCount
        var removed = 0
        while (over() && start < protect) {
            var end = start + 1
            while (end < protect && !isUserMessage(history[end])) end++
            repeat(end - start) { history.removeAt(start) }
            removed += end - start
            protect -= end - start
        }
        if (removed > 0 && start == seedCount) history.add(seedCount, userText(DROPPED_NOTE))
        return removed
    }

data class Overflow(val requestedTokens: Int?, val availableTokens: Int)

    private val llamaCpp = Regex("(?i)request \\((\\d+) tokens\\) exceeds the available context size \\((\\d+) tokens\\)")
    private val vllm = Regex("(?is)maximum context length is (\\d+) tokens.*?(?:requested|resulted in|you requested) (\\d+) tokens")

fun contextOverflow(failure: Throwable): Overflow?

The context window a refused request reports, from llama.cpp's or vLLM's wording, or null.

    fun contextOverflow(failure: Throwable): Overflow? {
        val seen = mutableSetOf<Throwable>()
        val message = generateSequence(failure) { it.cause }.takeWhile { seen.add(it) }.joinToString("\n") { it.message.orEmpty() }
        llamaCpp.find(message)?.let { return Overflow(it.groupValues[1].toInt(), it.groupValues[2].toInt()) }
        vllm.find(message)?.let { return Overflow(it.groupValues[2].toInt(), it.groupValues[1].toInt()) }
        return null
    }

fun inputBudget(contextTokens: Int, maxOutputTokens: Long): Long

Tokens a request may use for its input in a window of contextTokens, leaving room to answer.

    fun inputBudget(contextTokens: Int, maxOutputTokens: Long): Long =
        (contextTokens * 0.9).toLong() - maxOutputTokens.coerceAtMost(contextTokens / 4L)
}