ScriptedLlmBridge.kt
src/jvmMain/kotlin/afield/ai/models/ScriptedLlmBridge.kt · 210 lines
An LlmBridge that replays a fixed script of tool calls instead of asking a model.
Tests and the no-credentials mode of agent tasks use it to drive the real tool dispatch; ScriptedLlmBridge documents the script format.
package afield.ai.models
import afield.ai.conversation.AiTurnInbox
import afield.ai.conversation.PendingToolOutput
import afield.ai.conversation.SuspendedRound
import afield.ai.conversation.SuspendsTurn
import afield.ai.conversation.TurnContext
import afield.ai.conversation.TurnSuspension
import afield.ai.conversation.aiToolFailureEndsTurn
import afield.ai.conversation.aiToolFailureMessage
import afield.ai.tools.ToolExecutionContext
import afield.ai.tools.ToolRegistry
import afield.ai.tools.aiToolMapper
import afield.ai.tools.registerBuiltInAgentTools
import afield.ai.conversation.AiChatModelIdentity
import java.util.UUID
class ScriptedLlmBridge
An LlmBridge with no model behind it: it replays a script of tool calls and a final answer
through the real ToolRegistry, exactly as a provider bridge would, and records what it was given.
What it is for
Every test of the AI4 loop so far faked the provider's HTTP endpoint. That tests the bridge's wire handling, and it is the only way to; but it cannot test what happens after a tool call — whether the tools do what their descriptions say, whether a task's checker agrees with the document, whether a verdict is reported honestly — without writing an HTTP transcript for every case. This bridge makes those tests a list of calls.
It is also the no-credentials mode of AgentTask and of the demo document: a scripted run goes
through the same tool dispatch, the same Field execution and the same checker as a live one; only
the choice of calls is canned. Results carry modelIdentity.provider == "scripted" so a scripted
pass can never be mistaken for a model's.
Script
Step.Call runs one tool; Step.Final ends the turn with text. A call whose result suspends the turn
(SuspendsTurn) ends it too, and the next send carries on with the step after it. Arguments are JSON with
${Tool.field} placeholders, replaced by field of the most recent result of the tool whose
simple name is Tool — ${NewBoxRequest.uid} is the uid of the box the script just made. Each
Step.Call is one tool round. A script with no Step.Final ends with an empty answer; one that
outruns maxToolRounds stops there and reports LlmSendResult.hitToolRoundCap, like a real bridge.
class ScriptedLlmBridge(
private val script: List<Step>,
private val label: String = "scripted",
) : LlmBridge {
sealed class Step
data class Call(val tool: String, val argumentsJson: String = "{}") : Step()
data class Final(val text: String) : Step()
data class Received
What one send was handed, for assertions.
data class Received(
val instructions: String,
val prompt: String,
val fileText: String?,
val maxToolRounds: Int,
val availableTools: List<String>,
/** The turn in parts, when the caller built one; [prompt] is then its legacy one-string form. */
val turnContext: TurnContext? = null,
/** The suspended round this turn opened with, when it resumed a suspended one. */
val resumedRound: SuspendedRound? = null,
)
private val tools = ToolRegistry().also(::registerBuiltInAgentTools)
private var cursor = 0
val received = mutableListOf<Received>()
val interjections = mutableListOf<String>()
The user messages handed over between rounds (AiTurnInbox), as the model would have read them.
override val modelIdentity: AiChatModelIdentity get() = AiChatModelIdentity("scripted", label)
override fun startThread(fileKey: String, fileText: String, instructions: String): LlmThreadState
override fun startThread(fileKey: String, fileText: String, instructions: String): LlmThreadState =
LlmThreadState(
localThreadId = UUID.randomUUID().toString(),
conversationId = "scripted:${UUID.randomUUID()}",
fileKey = fileKey,
initialInstructions = instructions,
initialFileText = fileText
)
override fun send
body — 91 lines, from line 91
override fun send(
state: LlmThreadState,
userPrompt: String,
fileText: String?,
ctx: ToolExecutionContext,
vectorStoreIds: List<String>,
maxOutputTokens: Long,
maxToolRounds: Int,
imageDataUrls: List<String>,
onTextDelta: ((String) -> Unit)?,
onProgress: ((LlmProgressUpdate) -> Unit)?,
turnContext: TurnContext?
): LlmSendResult {
val available = tools.describe(ctx).mapNotNull {
((it as? kotlinx.serialization.json.JsonObject)?.get("name") as? kotlinx.serialization.json.JsonPrimitive)?.content
}
received += Received(state.initialInstructions, userPrompt, fileText, maxToolRounds, available, turnContext, state.suspendedRound)
state.suspendedRound = null
val transcript = mutableListOf<LlmToolTranscriptEntry>()
val callCtx = ctx.copy(
reportProgress = onProgress ?: ctx.reportProgress,
promoted = ctx.promoted ?: state.promotedTools,
progress = ctx.progress ?: onProgress?.let { p -> { line: String -> p(LlmProgressUpdate(line, phase = "tool")) } },
)
val latestByTool = mutableMapOf<String, String>()
var rounds = 0
var hitCap = false
var text = ""
var suspension: TurnSuspension? = null
while (cursor < script.size && suspension == null) {
when (val step = script[cursor]) {
is Step.Final -> { text = step.text; cursor++; break }
is Step.Call -> {
if (rounds >= maxToolRounds) { hitCap = true; break }
cursor++
rounds++
val args = substitute(step.argumentsJson, latestByTool)
onProgress?.invoke(LlmProgressUpdate("Using ${step.tool}", args.take(700), phase = "tool"))
val started = System.nanoTime()
val result: Any = try {
tools.executeByName(callCtx, step.tool, args)
} catch (e: Throwable) {
if (aiToolFailureEndsTurn(e)) throw e
mapOf("error" to true, "tool" to step.tool, "message" to aiToolFailureMessage(e))
}
val elapsedMs = (System.nanoTime() - started) / 1_000_000
val json = runCatching { aiToolMapper.writeValueAsString(result) }.getOrElse { result.toString() }
latestByTool[step.tool] = json
transcript += LlmToolTranscriptEntry(
step.tool, "scripted-$rounds", args, json.take(300), json, elapsedMs = elapsedMs
)
transcript += afield.ai.tools.program.nestedTranscriptEntries("scripted-$rounds", result)
onProgress?.invoke(LlmProgressUpdate("${step.tool} finished",
afield.ai.tools.program.nestedTraceMarkdown(result) ?: json.take(300), phase = "tool_result"))
// A suspending tool ends this turn, as on the provider bridges; the script carries on in the
// turn that resumes it, and the inbox is left for a turn of its own.
suspension = (result as? SuspendsTurn)?.suspension
if (suspension != null) {
state.suspendedRound = SuspendedRound(listOf(PendingToolOutput("scripted-$rounds", step.tool, json)))
continue
}
// As the provider bridges do after a round; the script does not change, it is only recorded.
callCtx.inbox?.drainForModel()?.takeIf { it.isNotEmpty() }?.let { interjections += AiTurnInbox.modelText(it) }
}
}
}
onTextDelta?.invoke(text)
return LlmSendResult(
threadState = state,
text = text,
rawResponseId = "scripted-${UUID.randomUUID()}",
toolTranscript = transcript,
modelIdentity = modelIdentity,
rounds = rounds,
hitToolRoundCap = hitCap,
suspension = suspension,
inputTokens = 0,
outputTokens = 0
)
}
private fun substitute(template: String, latest: Map<String, String>): String =
PLACEHOLDER.replace(template) { m ->
val (tool, field) = m.destructured
val node = latest[tool]?.let { runCatching { aiToolMapper.readTree(it) }.getOrNull() }
?: throw IllegalStateException("Script refers to \${$tool.$field} before any $tool result.")
val value = node.get(field) ?: throw IllegalStateException("$tool result has no field '$field'.")
// Insert as the inside of a JSON string: the template writes the quotes.
aiToolMapper.writeValueAsString(if (value.isTextual) value.asText() else value.toString()).removeSurrounding("\"")
}
companion object
companion object {
A placeholder names a tool, and every tool's simple name ends in Request; anything else in ${a.b} form is
left alone, so box code written with Kotlin string templates ("${it.name}") survives a script unchanged.
private val PLACEHOLDER = Regex("""\$\{([A-Z][A-Za-z0-9_]*Request)\.([A-Za-z0-9_]+)}""")
fun parse(json: String): List<Step>
Parse a script from JSON: [{"tool":"NewBoxRequest","arguments":{...}}, {"final":"done"}].
This is the form agent-task --script and the HTTP route accept.
fun parse(json: String): List<Step> {
val root = aiToolMapper.readTree(json)
require(root.isArray) { "A script is a JSON array of steps." }
return root.map { node ->
when {
node.has("final") -> Step.Final(node.get("final").asText())
node.has("tool") -> Step.Call(
node.get("tool").asText(),
node.get("arguments")?.let { aiToolMapper.writeValueAsString(it) } ?: "{}"
)
else -> throw IllegalArgumentException("Script step needs 'tool' or 'final': $node")
}
}
}
}
}