Skip to content

TurnSuspension.kt

src/jvmMain/kotlin/afield/ai/conversation/TurnSuspension.kt · 77 lines

package afield.ai.conversation

import afield.ai.models.OpenAiIdeCodexBridge

How a tool result ends a model's turn early so the conversation can wait for study builds without spending model calls.

A tool result implementing SuspendsTurn carries a TurnSuspension: the AwaitedBuild jobs and a deadline. The bridge stops after the current round and keeps that round's tool outputs as a SuspendedRound, which the resuming turn sends ahead of its own prompt.

interface SuspendsTurn

A tool result that ends the model's turn when suspension is set: the bridge finishes the round it is in, makes no further model request, and returns the suspension in OpenAiIdeCodexBridge.SendResult. Someone else resumes the conversation later (StudyBuildAwaits for AwaitStudyBuildsRequest), so the time spent waiting costs no model calls.

The round's tool outputs, this one's included, are not sent at once; SuspendedRound keeps them for the next turn, which opens with them ahead of its own prompt.

val suspension: TurnSuspension?

data class TurnSuspension

What a suspended turn waits for: study-build jobs and how long it may wait for them, or the person's answer to a question (AskUserQuestionRequest), which has no deadline: their next message is the answer.

data class TurnSuspension(
    val jobs: List<AwaitedBuild> = emptyList(),
    /** Epoch milliseconds after which the conversation resumes whatever the jobs' state; 0 with no jobs. */
    val deadlineMs: Long = 0L,
    val question: AskedQuestion? = null,
) {

fun merge(other: TurnSuspension): TurnSuspension

One round may await more than once (two runners): wait for every job, until the later deadline. The first question stands.

    fun merge(other: TurnSuspension): TurnSuspension =
        TurnSuspension((jobs + other.jobs).distinctBy { Triple(it.boxId, it.name, it.jobId) }, maxOf(deadlineMs, other.deadlineMs),
            question ?: other.question)

fun describe(): String

For logs: what the turn waits for.

    fun describe(): String = listOfNotNull(
        jobs.takeIf { it.isNotEmpty() }?.let { "${it.size} build job(s)" },
        question?.let { "the person's answer" },
    ).joinToString(" and ")

companion object

fun of(results: List<Any?>): TurnSuspension?

        fun of(results: List<Any?>): TurnSuspension? =
            results.mapNotNull { (it as? SuspendsTurn)?.suspension }.reduceOrNull(TurnSuspension::merge)
    }
}

data class AskedQuestion(val question: String, val choices: List<String> = emptyList())

A question the agent asked the person, and the answers it suggests (possibly none).

data class AwaitedBuild

One awaited attempt: the runner (its box and name), the job, and what to call it in the chat.

data class AwaitedBuild(
    val boxId: String,
    val name: String,
    val jobId: String,
    val stage: String = "",
    val label: String = "",
    /** The runner's directory, so a job can still be read from disk when the runner is not running. */
    val directory: String = "",
)

data class SuspendedRound(val outputs: List<PendingToolOutput>)

The tool outputs of the round that suspended a turn, which the provider has not seen yet. The next turn sends them first: on OpenAI's chained responses its previous_response_id is still the response that made these calls, and every call must have an output before anything else can follow it; on Anthropic, whose history this bridge keeps, the tool_use blocks need their tool_results in the next user message.

data class PendingToolOutput(val callId: String, val name: String, val outputJson: String, val isError: Boolean = false)

A finished call's output as the provider will receive it. isError marks a failed call (Anthropic).

data class PendingToolOutput(val callId: String, val name: String, val outputJson: String, val isError: Boolean = false)