AiToolFailure.kt
src/jvmMain/kotlin/afield/ai/conversation/AiToolFailure.kt · 46 lines
package afield.ai.conversation
What a chat turn does when a tool throws. Three answers live here: which failures end the turn rather than becoming the call's output, what the model reads in place of a result, and what is written back for a call the turn ended before answering.
The provider's thread has to hold an output for every call it made, so a turn that stops mid-round still writes one for each call left outstanding; without it, every later turn in that chat is refused for a function call with no output.
internal fun aiToolFailureEndsTurn(failure: Throwable): Boolean
Whether a tool's failure must end the turn instead of becoming the call's output: the turn's own
cancellation, or a VirtualMachineError the JVM may not survive (OutOfMemoryError, InternalError, ...).
A StackOverflowError is not one of them: once it has unwound to the tool loop, the stack is back, so it
is answered like any other failure. Everything else a tool throws, every other Error included, is the
call's output: on 2026-10-18 a box tool's StackOverflowError ended a turn with its function call
unanswered, and every later turn in that chat was refused with "No tool output found for function call".
internal fun aiToolFailureEndsTurn(failure: Throwable): Boolean =
isAiTurnCancellation(failure) || (failure is VirtualMachineError && failure !is StackOverflowError)
internal fun aiToolFailureMessage(failure: Throwable): String
What the model reads when a tool threw failure. An exception keeps its own message, which a tool
writes for the model; an Error rarely has one, so it is named, with the frame it was thrown from.
internal fun aiToolFailureMessage(failure: Throwable): String {
if (failure is Exception) return failure.message ?: failure::class.java.name
val frame = failure.stackTrace.firstOrNull()?.let { " at ${it.className.substringAfterLast('.')}.${it.methodName}" +
(it.fileName?.let { file -> "($file${if (it.lineNumber > 0) ":${it.lineNumber}" else ""})" } ?: "") }.orEmpty()
return "Tool failed: ${failure::class.java.simpleName}$frame" + failure.message?.let { ": $it" }.orEmpty()
}
internal fun aiToolNotRunOutput(callId: String, name: String, failure: Throwable): PendingToolOutput
The output given to a call the turn ended before answering, so that the provider's thread holds an
output for every call it made and the next turn can follow it. failure is what ended the turn.
internal fun aiToolNotRunOutput(callId: String, name: String, failure: Throwable): PendingToolOutput {
val reason = if (isAiTurnCancellation(failure)) "the turn was cancelled"
else "the turn ended with ${failure::class.java.simpleName}${failure.message?.let { ": $it" }.orEmpty()}"
val json = afield.ai.tools.aiToolMapper.writeValueAsString(mapOf("error" to true, "aborted" to true, "tool" to name,
"message" to "Not finished: $reason before this call returned. It may have partly run; check before repeating it."))
return PendingToolOutput(callId, name, json, isError = true)
}