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)