AnthropicBridge.kt
src/jvmMain/kotlin/afield/ai/models/AnthropicBridge.kt · 757 lines
The Anthropic (Claude) implementation of LlmBridge, running Field's agent loop over the Messages API.
It uses the same ToolRegistry and tool definitions as OpenAiIdeCodexBridge. The Messages API
keeps no server-side conversation, so AnthropicBridge holds each thread's history locally and
marks the system prompt and tool list for prompt caching.
package and imports — 60 lines, from line 9
package afield.ai.models
import afield.ai.tools.DescribeToolsRequest
import afield.ai.tools.ToolImage
import afield.ai.conversation.AiRateLimitRecovery
import afield.ai.conversation.AiRequestLedger
import afield.ai.conversation.AiTurnInbox
import afield.ai.conversation.PendingToolOutput
import afield.ai.conversation.Stability
import afield.ai.conversation.SuspendedRound
import afield.ai.conversation.TurnContext
import afield.ai.conversation.TurnSuspension
import afield.ai.conversation.aiToolFailureEndsTurn
import afield.ai.conversation.aiToolFailureMessage
import afield.ai.conversation.aiToolNotRunOutput
import afield.ai.conversation.isAiTurnCancellation
import afield.ai.conversation.toolActivity
import afield.ai.conversation.userNotice
import afield.ai.conversation.withTools
import afield.ai.diagnostics.AiToolIoLogger
import afield.ai.diagnostics.LogSink
import afield.ai.diagnostics.StdoutLog
import afield.ai.diagnostics.log
import afield.ai.tools.CarriesToolImages
import afield.ai.tools.Commentary
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 com.anthropic.client.AnthropicClient
import com.anthropic.core.JsonValue
import com.anthropic.errors.UnauthorizedException
import com.anthropic.models.messages.Base64ImageSource
import com.anthropic.models.messages.CacheControlEphemeral
import com.anthropic.models.messages.ContentBlockParam
import com.anthropic.models.messages.ImageBlockParam
import com.anthropic.models.messages.Message
import com.anthropic.models.messages.MessageCreateParams
import com.anthropic.models.messages.MessageParam
import com.anthropic.models.messages.StopReason
import com.anthropic.models.messages.Tool
import com.anthropic.models.messages.ToolChoiceNone
import com.anthropic.models.messages.TextBlockParam
import com.anthropic.models.messages.ToolResultBlockParam
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
class AnthropicBridge
Anthropic (Claude) implementation of LlmBridge, mirroring OpenAiIdeCodexBridge's
agent loop over the Messages API.
Reuses the shared tool layer verbatim: registerBuiltInAgentTools populates the same
ToolRegistry the OpenAI bridge uses, and ToolRegistry.describe/ToolRegistry.executeByName
provide the provider-neutral tool enumeration + dispatch. The Messages API is stateless
(no server-side conversation / previous_response_id), so history is kept locally per thread —
the same shape as the OpenAI bridge's OpenRouter statelessHistory branch.
v1 runs the loop non-streaming and delivers assistant text through onTextDelta at the end;
onProgress (tool use/finish, commentary) works live. True token streaming via
client.messages().createStreaming(...) and image/thinking support are follow-ups.
class AnthropicBridge(
private var client: AnthropicClient = anthropicClientFromFieldConfig(),
private val model: String = Ai4ModelDefaults.flagshipModel(),
private val log: LogSink = StdoutLog,
/** Where [client] sends its requests, for the request ledger: the SDK client does not say. */
private val baseUrl: String = configuredAnthropicBaseUrl() ?: ANTHROPIC_DEFAULT_BASE_URL
) : LlmBridge {
override val modelIdentity: AiChatModelIdentity get() = AiChatModelIdentity("anthropic", model)
private val tools = ToolRegistry()
The same per-turn JSONL the OpenAI bridge writes (~/.afield/logs/ai4_tool_io/). Without it the
tool-use improvement loop, and any usage count taken from those logs, only ever saw OpenAI turns.
private val toolIoLog: AiToolIoLogger? = AiToolIoLogger.fromEnvOrDefault(log)
Per-thread message history (stateless API — we resend the full transcript each turn).
private val history = ConcurrentHashMap<String, MutableList<MessageParam>>()
private val rateLimitRecovery = AiRateLimitRecovery.fromConfig()
As on OpenAiIdeCodexBridge, Field owns the retries, the deadline and the visible wait, so the SDK makes none:
each HTTP request is then one attempt in the request ledger, and a 429 the SDK would have absorbed has its record.
Taken from client on every request because a 401 replaces it.
private fun messageService() = client.messages().withOptions { it.maxRetries(0) }
private fun info(message: String) = log.log("info,ai.bridge.anthropic", message)
private fun warn(message: String) = log.log("warning,ai.bridge.anthropic", message)
private fun error(message: String) = log.log("error,ai.bridge.anthropic", message)
init
init {
registerBuiltInAgentTools(tools)
info("Initialized AnthropicBridge(model=$model)")
}
fun <T : Any> registerTool(toolClass: Class<T>, handler: (ToolExecutionContext, T) -> Any) {
As OpenAiIdeCodexBridge.registerTool: one more tool for this bridge only (tests use it for a probe).
fun <T : Any> registerTool(toolClass: Class<T>, handler: (ToolExecutionContext, T) -> Any) {
tools.register(toolClass, handler = handler)
}
override fun startThread(fileKey: String, fileText: String, instructions: String): LlmThreadState
override fun startThread(fileKey: String, fileText: String, instructions: String): LlmThreadState {
val localThreadId = UUID.randomUUID().toString()
val state = OpenAiIdeCodexBridge.ThreadState(
localThreadId = localThreadId,
conversationId = "anthropic:$localThreadId",
fileKey = fileKey,
initialInstructions = instructions.ifBlank { OpenAiIdeCodexBridge.defaultThreadInstructions },
initialFileText = fileText
)
info("startThread(): fileKey=$fileKey localThreadId=$localThreadId")
return state
}
override fun send
body — 352 lines, from line 139
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 t0 = System.nanoTime()
if (imageDataUrls.isNotEmpty()) {
warn("imageDataUrls (${imageDataUrls.size}) ignored — image input is a v1 follow-up for the Anthropic bridge.")
}
if (vectorStoreIds.isNotEmpty()) {
warn("vectorStoreIds ignored — Anthropic has no hosted file_search; wire retrieval as a tool instead.")
}
val system = state.initialInstructions.ifBlank { OpenAiIdeCodexBridge.defaultThreadInstructions }
val messages = history.getOrPut(state.localThreadId) { mutableListOf() }
// With a TurnContext: the stable instructions ride in the system prompt (cached), the volatile ones
// on the newest message only, and the stored history keeps just the durable prompt.
val tc = turnContext
val systemBlocks = anthropicSystemBlocks(system, tc?.ephemeralText(Stability.STABLE).orEmpty(), cached = tc != null)
val volatilePrefix = tc?.ephemeralText(Stability.VOLATILE).orEmpty()
// Seed the initial file context once per thread (mirrors OpenAiIdeCodexBridge.startThread).
if (messages.isEmpty() && state.initialFileText.isNotBlank()) {
messages += userText(
buildString {
appendLine("IDE_FILE_CONTEXT:")
appendLine("key: ${state.fileKey}")
appendLine("content:")
appendLine(state.initialFileText)
}
)
}
val inputText = buildString {
if (fileText != null) {
appendLine("IDE_FILE_UPDATE:")
appendLine("key: ${state.fileKey}")
appendLine("content:")
appendLine(fileText)
appendLine()
}
appendLine(tc?.durablePrompt() ?: userPrompt)
}
// After a suspended turn, its last round's tool results open this turn's message, ahead of the prompt.
val suspendedResults = state.suspendedRound?.outputs.orEmpty()
fun turnMessage(text: String): MessageParam = if (suspendedResults.isEmpty()) userText(text) else
MessageParam.builder().role(MessageParam.Role.USER).contentOfBlockParams(
suspendedResults.map { ContentBlockParam.ofToolResult(toolResultBlock(it.callId, it.outputJson, it.isError, it.outputJson, ::warn)) } +
ContentBlockParam.ofText(TextBlockParam.builder().text(text).build())).build()
val turnMessageIndex = messages.size
messages += turnMessage(if (volatilePrefix.isEmpty()) inputText else "$volatilePrefix\n\n$inputText")
// In the history now, so a retry after a failed request does not send the results twice.
state.suspendedRound = null
info("convId=${state.conversationId} fileKey=${state.fileKey} inputChars=${inputText.length} maxOutputTokens=$maxOutputTokens")
// Field names match OpenAiIdeCodexBridge's, so one reader (tools/ai_tool_usage.py, the
// improvement monitor) covers both providers.
val toolIoSession = runCatching {
toolIoLog?.begin(
mapOf(
"model" to model,
"provider" to "anthropic",
"reasoningEffort" to "provider-default",
"conversationId" to state.conversationId,
"localThreadId" to state.localThreadId,
"fileKey" to state.fileKey,
"inputChars" to inputText.length,
"attachmentCount" to ctx.attachments.size,
"imageCount" to imageDataUrls.size,
"maxOutputTokens" to maxOutputTokens,
"maxToolRounds" to maxToolRounds,
"prompt" to inputText
)
)
}.onFailure { warn("tool I/O log unavailable: ${it.message ?: it::class.java.simpleName}") }.getOrNull()
var toolIoFailureReported = false
fun toolIo(event: String, fields: Map<String, Any?> = emptyMap()) {
val session = toolIoSession ?: return
val logger = toolIoLog ?: return
runCatching { logger.event(session, event, fields) }.onFailure {
if (!toolIoFailureReported) {
toolIoFailureReported = true
warn("tool I/O log write failed: ${it.message ?: it::class.java.simpleName}")
}
}
}
// The request ledger's attribution, as OpenAiIdeCodexBridge's, so tools/ai_usage.py counts both providers.
val requestAttribution = mapOf(
"provider" to "anthropic", "endpoint" to AiRequestLedger.endpoint(baseUrl),
"model" to model, "reasoningEffort" to "provider-default",
"fileKey" to state.fileKey,
"documentPath" to (ctx.focus?.document ?: ctx.fieldGraph?.info?.filename),
"documentName" to ctx.fieldGraph?.info?.documentName,
"conversationId" to state.conversationId, "localThreadId" to state.localThreadId,
"turnId" to UUID.randomUUID().toString(), "toolIoSessionId" to toolIoSession?.id
)
fun requestLedger(phase: String) = AiRequestLedger.request(requestAttribution + mapOf(
"phase" to phase, "maxOutputTokens" to maxOutputTokens
))
val callCtx = ctx.copy(
reportProgress = onProgress ?: ctx.reportProgress,
promoted = ctx.promoted ?: state.promotedTools,
progress = ctx.progress ?: onProgress?.let { p -> { line: String -> p(LlmProgressUpdate(line)) } },
)
// Rebuilt each round: a DescribeToolsRequest in one round adds schemas to the next.
fun toolDefs() = buildToolDefs(callCtx).let { if (tc != null) withToolCacheBreakpoint(it) else it }
val commentary = mutableListOf<String>()
val conversationAdditions = mutableListOf<String>()
val transcript = mutableListOf<LlmToolTranscriptEntry>()
var inputTokens = 0L
var outputTokens = 0L
var cacheReadTokens: Long? = null
var cacheWriteTokens: Long? = null
fun countUsage(message: Message) {
runCatching { message.usage() }.getOrNull()?.let {
inputTokens += it.inputTokens()
outputTokens += it.outputTokens()
it.cacheReadInputTokens().orElse(null)?.let { n -> cacheReadTokens = (cacheReadTokens ?: 0L) + n }
it.cacheCreationInputTokens().orElse(null)?.let { n -> cacheWriteTokens = (cacheWriteTokens ?: 0L) + n }
}
}
var toolElapsedMs = 0L
var activePhase = "model:initial"
// The tool_use blocks at the end of the history that have no tool_result after them yet, and the
// results this turn has for them so far. A turn that fails while there are any leaves a result for
// each to the next one, whose message must open with them.
var openCalls: List<OpenAiIdeCodexBridge.PendingToolCall> = emptyList()
var openOutputs: List<PendingToolOutput> = emptyList()
try {
var hitToolRoundCap = false
var response = createMessage(buildParams(systemBlocks, messages, toolDefs(), maxOutputTokens, cacheTail = tc != null), requestLedger("initial"), onProgress)
countUsage(response)
onProgress?.invoke(LlmProgressUpdate("", modelIdentity = modelIdentity.copy(responseModel = response.model().asString())))
messages += response.toParam()
logShape("initial", response)
var rounds = 0
var suspension: TurnSuspension? = null
while (response.stopReason().orElse(null) == StopReason.TOOL_USE) {
val toolUses = response.content().mapNotNull { it.toolUse().orElse(null) }
if (toolUses.isEmpty()) break
val resultBlocks = mutableListOf<ContentBlockParam>()
val roundResults = mutableListOf<Any>()
val roundOutputs = mutableListOf<PendingToolOutput>()
openCalls = toolUses.map { OpenAiIdeCodexBridge.PendingToolCall(it.id(), it.name()) }
openOutputs = roundOutputs
for (use in toolUses) {
val name = use.name()
log.toolActivity(name)
val argsJson = runCatching { toJsonString(use._input()) }.getOrElse { "{}" }
info("tool: name=$name id=${use.id()} args=${truncate(argsJson, 400)}")
toolIo("tool_call", mapOf("round" to (rounds + 1), "toolName" to name, "callId" to use.id(), "arguments" to argsJson))
onProgress?.invoke(LlmProgressUpdate("Using $name", truncate(argsJson, 700)))
activePhase = "tool:$name"
val started = System.nanoTime()
val result: Any = try {
tools.executeByName(callCtx, name, argsJson)
} catch (e: Throwable) {
// A cancelled turn must end the turn, not become a tool error the model is asked about.
if (aiToolFailureEndsTurn(e)) throw e
val message = aiToolFailureMessage(e)
error("tool: ERROR name=$name msg=$message")
toolIo("tool_exception", mapOf(
"round" to (rounds + 1), "toolName" to name, "callId" to use.id(),
"exceptionType" to e::class.java.name, "message" to message))
mapOf("error" to true, "tool" to name, "message" to message)
}
val toolMs = (System.nanoTime() - started) / 1_000_000
toolElapsedMs += toolMs
val resultJson = runCatching { aiToolMapper.writeValueAsString(result) }.getOrElse { result.toString() }
if (runCatching { aiToolMapper.readTree(resultJson).let { it["success"]?.asBoolean() == false || it["error"]?.asBoolean() == true } }.getOrDefault(false)) {
log.userNotice("An action could not finish. Details are in the chat and Tool pool.", attention = true)
}
val summary = truncate(resultJson, 300)
transcript += LlmToolTranscriptEntry(name, use.id(), argsJson, summary, resultJson, elapsedMs = toolMs)
transcript += afield.ai.tools.program.nestedTranscriptEntries(use.id(), result)
toolIo("tool_result", mapOf(
"round" to (rounds + 1), "toolName" to name, "callId" to use.id(), "summary" to summary,
"elapsedMs" to toolMs, "result" to resultJson,
"imageCount" to ((result as? CarriesToolImages)?.toolImages?.size ?: 0)))
afield.ai.tools.program.nestedToolIoEvents(rounds + 1, use.id(), result).forEach { (event, fields) -> toolIo(event, fields) }
onProgress?.invoke(LlmProgressUpdate("$name finished ($toolMs ms)", afield.ai.tools.program.nestedTraceMarkdown(result) ?: summary))
if (name == Commentary::class.java.simpleName) {
runCatching { aiToolMapper.readValue(argsJson.ifBlank { "{}" }, Commentary::class.java).text }
.onSuccess { text ->
commentary += text
onProgress?.invoke(LlmProgressUpdate("Commentary", text))
}
}
// The panel's copy of the conversation.
//
// The *model* gets these images inside the `tool_result`, via `toolResultBlock` — which
// is the better shape here and is why this bridge nests rather than appending. But the
// panel is plain markdown assembled from `conversationAdditions`, and this bridge only
// ever put prose there. So a ReloadShaders visual check rendered as a sentence claiming
// a screenshot had been captured, with no screenshot under it.
//
// It used to have a second job, worth recording because it is why the omission went
// unnoticed for so long: `Ai4Installer` decided whether the render window could be
// captured *at all* by searching the rendered history for a `data:image/`. Nothing on
// this provider ever wrote one, so that test could never come true, so `visualCheck`
// was refused in perpetuity and the missing attachment here had nothing to attach.
// That gate is gone; this block is now only about what the person sees.
val carried = (result as? CarriesToolImages)?.toolImages
.orEmpty()
.filter { it.dataUrl.isNotBlank() }
if (carried.isNotEmpty()) {
conversationAdditions += buildString {
appendLine("<<ai>>")
append(modelIdentity.copy(responseModel = response.model().asString()).marker())
carried.forEachIndexed { index, image ->
if (index > 0) appendLine()
if (image.caption.isNotBlank()) {
appendLine("_${image.caption}_")
appendLine()
}
append("
append(image.dataUrl)
append(")")
appendLine()
}
}
}
val isError = (result as? Map<*, *>)?.get("error") == true
resultBlocks += ContentBlockParam.ofToolResult(
toolResultBlock(use.id(), resultJson, isError, result, ::warn)
)
roundResults += result
roundOutputs += PendingToolOutput(use.id(), name, resultJson, isError)
}
// A tool ended the turn (AwaitStudyBuildsRequest, AskUserQuestionRequest): no follow-up request. The results open the next
// turn's message instead; what the person wrote meanwhile stays in the inbox for a turn of its own.
TurnSuspension.of(roundResults)?.let { awaited ->
suspension = awaited
state.suspendedRound = SuspendedRound(roundOutputs.toList())
openCalls = emptyList()
rounds++
info("turn suspended after round $rounds: waiting for ${awaited.describe()} without the model")
toolIo("turn_suspended", mapOf("round" to rounds, "jobIds" to awaited.jobs.map { it.jobId },
"deadlineMs" to awaited.deadlineMs, "pendingCallIds" to roundOutputs.map { it.callId }))
}
if (suspension != null) break
// What the person wrote while this round ran (AiTurnInbox): a text block after the tool results,
// which must come first in the message. Their images are dropped, as a turn's own are here.
callCtx.inbox?.drainForModel()?.takeIf { it.isNotEmpty() }?.let { taken ->
val text = AiTurnInbox.modelText(taken)
resultBlocks += ContentBlockParam.ofText(TextBlockParam.builder().text(text).build())
if (taken.any { it.images.isNotEmpty() }) warn("images sent while the turn ran are ignored by the Anthropic bridge.")
info("user interjection: ${taken.size} message(s), ${text.length} chars")
toolIo("user_interjection", mapOf("round" to (rounds + 1), "messages" to taken.size, "text" to text))
}
messages += MessageParam.builder()
.role(MessageParam.Role.USER)
.contentOfBlockParams(resultBlocks)
.build()
openCalls = emptyList()
rounds++
// At the round cap, force a final answer (results are already paired above) by switching tool use
// off rather than leaving the tools out: the Messages API 400s a request whose messages carry
// tool_use/tool_result blocks but which defines no tools ("must define tools"), and a changed tool
// list would also miss the cached prefix. Nothing is added to the messages, so no later turn
// inherits a stop.
val includeTools = rounds < maxToolRounds
if (!includeTools) {
warn("maxToolRounds=$maxToolRounds reached; requesting a final answer with tool_choice none.")
}
if (!includeTools) hitToolRoundCap = true
val phase = if (includeTools) "followup" else "forced_final"
activePhase = "model:$phase"
response = createMessage(buildParams(systemBlocks, messages, toolDefs(), maxOutputTokens, cacheTail = tc != null, toolsDisabled = !includeTools),
requestLedger(phase), onProgress)
countUsage(response)
onProgress?.invoke(LlmProgressUpdate("", modelIdentity = modelIdentity.copy(responseModel = response.model().asString())))
messages += response.toParam()
logShape(phase, response)
if (!includeTools) break
}
val text = response.content().mapNotNull { it.text().orElse(null)?.text() }.joinToString("")
if (text.isBlank()) {
warn("model returned no text. stopReason=${response.stopReason().orElse(null)} — try higher maxOutputTokens.")
}
onTextDelta?.invoke(text)
val ms = (System.nanoTime() - t0) / 1_000_000
info("done id=${response.id()} textChars=${text.length} commentary=${commentary.size} rounds=$rounds (${ms}ms)")
toolIo("session_end", mapOf(
"responseId" to response.id(), "textChars" to text.length, "commentaryCount" to commentary.size,
"toolCallCount" to transcript.size, "elapsedMs" to ms, "status" to if (suspension != null) "suspended" else "completed",
"toolElapsedMs" to toolElapsedMs, "rounds" to rounds,
"inputTokens" to inputTokens, "outputTokens" to outputTokens,
"cacheReadTokens" to cacheReadTokens, "cacheWriteTokens" to cacheWriteTokens))
return OpenAiIdeCodexBridge.SendResult(
threadState = state,
text = text,
rawResponseId = response.id(),
commentary = commentary.toList(),
conversationAdditions = conversationAdditions.toList(),
toolTranscript = transcript.toList(),
modelIdentity = modelIdentity.copy(responseModel = response.model().asString()),
rounds = rounds,
hitToolRoundCap = hitToolRoundCap,
suspension = suspension,
inputTokens = inputTokens,
outputTokens = outputTokens,
cacheReadTokens = cacheReadTokens,
cacheWriteTokens = cacheWriteTokens,
contextReport = tc?.report("system+prefix")
?.copy(inputTokens = inputTokens, cacheReadTokens = cacheReadTokens, cacheWriteTokens = cacheWriteTokens)
?.withTools(tools, callCtx)
)
} catch (failure: Throwable) {
val cancelled = isAiTurnCancellation(failure)
if (openCalls.isNotEmpty()) runCatching {
val byId = openOutputs.associateBy { it.callId }
state.suspendedRound = SuspendedRound(openCalls.map { byId[it.callId] ?: aiToolNotRunOutput(it.callId, it.name, failure) })
warn("turn failed with ${openCalls.size} tool_use block(s) unanswered; the next turn's message opens with their results")
}
toolIo(if (cancelled) "session_cancelled" else "session_failed", mapOf(
"status" to if (cancelled) "cancelled" else "failed",
"phase" to activePhase,
"elapsedMs" to (System.nanoTime() - t0) / 1_000_000,
"toolElapsedMs" to toolElapsedMs,
"exceptionType" to failure::class.java.name, "message" to (failure.message ?: "")))
throw failure
} finally {
// The volatile prefix was for this turn's requests only. Whatever happened, the stored message
// goes back to the durable text, so the next turn's history does not carry it.
if (volatilePrefix.isNotEmpty() && turnMessageIndex < messages.size) messages[turnMessageIndex] = turnMessage(inputText)
}
}
request construction
internal fun buildParams
internal fun buildParams(
system: List<TextBlockParam>,
messages: List<MessageParam>,
toolDefs: List<Tool>,
maxOutputTokens: Long,
cacheTail: Boolean = false,
toolsDisabled: Boolean = false
): MessageCreateParams {
val builder = MessageCreateParams.builder()
.model(model)
.maxTokens(maxOutputTokens)
.systemOfTextBlockParams(system)
.messages(messages)
// Automatic caching: a breakpoint that moves with the end of the conversation, so each tool round
// reads the rounds before it from cache. With the tool and system breakpoints that makes three of four.
if (cacheTail) builder.cacheControl(CacheControlEphemeral.builder().build())
toolDefs.forEach { builder.addTool(it) }
// `none`, unlike `any`/`tool`, is accepted by every current model.
if (toolsDisabled) builder.toolChoice(ToolChoiceNone.builder().build())
return builder.build()
}
private fun userText(text: String): MessageParam =
MessageParam.builder().role(MessageParam.Role.USER).content(text).build()
Build Anthropic Tool defs from the shared registry's provider-neutral descriptors.
private fun buildToolDefs(ctx: ToolExecutionContext): List<Tool> =
tools.describe(ctx).mapNotNull { element ->
val obj = element.jsonObject
val name = obj["name"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
val desc = obj["description"]?.jsonPrimitive?.contentOrNull
val schema = obj["inputSchema"]?.jsonObject
val props = schema?.get("properties")?.jsonObject ?: JsonObject(emptyMap())
val required = (schema?.get("required") as? JsonArray)
?.mapNotNull { it.jsonPrimitive.contentOrNull }
.orEmpty()
val propsBuilder = Tool.InputSchema.Properties.builder()
props.forEach { (key, value) -> propsBuilder.putAdditionalProperty(key, JsonValue.from(kxToJava(value))) }
val inputSchema = Tool.InputSchema.builder()
.properties(propsBuilder.build())
.apply { if (required.isNotEmpty()) required(required) }
.build()
Tool.builder()
.name(name)
.apply { if (!desc.isNullOrBlank()) description(desc) }
.inputSchema(inputSchema)
.build()
}
create with rate-limit + auth-refresh retry
One logical request: each create is an attempt in ledger, recorded before the call and after its outcome.
rateLimitRecovery waits out 429, 529 overloaded_error and the SDK's other retryable failures (408, 409, 5xx,
connection errors), honouring retry-after-ms, retry-after and x-should-retry as the SDK did; each wait is a
retry_wait. A 401 refreshes the OAuth token and is retried once per logical request.
private fun createMessage(params: MessageCreateParams, ledger: AiRequestLedger.Request, onProgress: ((LlmProgressUpdate) -> Unit)?): Message {
fun attempt(): Message {
val recorded = ledger.attempt()
try {
return messageService().create(params).also { recorded.response(it); recorded.finish() }
} catch (e: Exception) {
recorded.finish(e)
throw e
}
}
var refreshedAuth = false
var lastRetry = 0
return rateLimitRecovery.run(
onWait = { wait ->
if (lastRetry != wait.retry) {
lastRetry = wait.retry
ledger.retry(wait.retry, wait.remainingMs)
log.userNotice("The AI service is busy. Retrying automatically in ${(wait.remainingMs + 999) / 1000}s; completed work is kept.", attention = true)
warn("messages-create temporarily unavailable; retry ${wait.retry} in ${wait.remainingMs}ms")
}
onProgress?.invoke(LlmProgressUpdate(
"Provider busy · retrying in ${(wait.remainingMs + 999) / 1000}s",
"Waiting automatically. Completed tool work is retained.", phase = "model"
))
},
onRetry = { onProgress?.invoke(LlmProgressUpdate("Waiting for model", phase = "model")) }
) {
try {
attempt()
} catch (e: UnauthorizedException) {
if (refreshedAuth) throw e
warn("401 from Anthropic; refreshing OAuth token and retrying once.")
invalidateAnthropicOAuthToken()
client = anthropicClientFromFieldConfig(forceRefresh = true)
refreshedAuth = true
attempt()
}
}
}
helpers
private fun logShape(phase: String, resp: Message) {
val text = resp.content().count { it.isText() }
val toolUse = resp.content().count { it.isToolUse() }
info("$phase response id=${resp.id()} text=$text toolUse=$toolUse stop=${resp.stopReason().orElse(null)}")
}
private fun toJsonString(input: JsonValue): String =
aiToolMapper.writeValueAsString(input.convert(Any::class.java))
private fun truncate(s: String, max: Int): String = if (s.length <= max) s else s.take(max) + "…"
private fun kxToJava(e: JsonElement): Any? = when (e) {
is JsonNull -> null
is JsonObject -> e.mapValues { kxToJava(it.value) }
is JsonArray -> e.map { kxToJava(it) }
is JsonPrimitive -> when {
e.isString -> e.content
e.booleanOrNull != null -> e.booleanOrNull
e.longOrNull != null -> e.longOrNull
e.doubleOrNull != null -> e.doubleOrNull
else -> e.content
}
}
}
Tool results that carry pictures
internal const val ANTHROPIC_MAX_IMAGE_BASE64_CHARS = 5 * 1024 * 1024
The largest base64 payload this will send for one image, in characters.
The Messages API rejects an oversized image with a 400 that fails the whole turn, taking the text
result down with a picture that was only ever a bonus. Five megabytes of base64 is comfortably under
the documented per-request ceiling and far above anything Field's CPU rasteriser produces — the
640×480 render in public/docs/assets/ai_tool_demo/ is 84 KB, so about 112 KB of base64, roughly 2%
of this. A tool that manages to exceed it loses its image and keeps its answer.
internal fun toolResultBlock
Build the tool_result block for one tool call, attaching any images the result carries.
Why this is a function rather than four lines inline
It was four lines inline, and the four lines were wrong in a way nothing could see. CarriesToolImages
is documented as "the bridge appends the images to the conversation", and this bridge never read the
interface at all: every tool result went out as content(resultJson), a bare string. So under
AFIELD_AI_PROVIDER=anthropic a geometry tool's whole point — letting the model look at what it
did — silently evaporated, and the only symptom was a model reasoning about a picture it had never
been shown.
It was an unfinished feature rather than a limitation. The Messages API has accepted images inside
a tool_result's content for as long as this bridge has existed, and the SDK models it as
ToolResultBlockParam.Content.Block.ofImage. Nothing needed inventing; the conversion was missing.
Pulling it out here is what makes it testable without a network or an API key: AnthropicToolImageTest
builds the real parameter object and asserts the image block is in it, which is the object the SDK
serialises into the request body.
Shape
Images go inside the tool_result rather than in a following user message — which is what the OpenAI
bridge does (OpenAiIdeCodexBridge.kt:433) because the Responses API has no equivalent nesting. Here
the nesting exists and is better: the picture is bound to the call it came from, so a turn with three
tool calls cannot leave the model guessing which image answers which.
Each image is preceded by its caption as a text block, for the same reason the OpenAI path does it: a bare picture in a conversation is much less useful than one that says what question it answers.
@param onOversize called with a human-readable reason when an image is dropped, so a silent drop is at least a logged one. Dropping the image and keeping the text is deliberate: a 400 from an oversized attachment would lose the answer as well.
body — 47 lines, from line 669
internal fun toolResultBlock(
toolUseId: String,
resultJson: String,
isError: Boolean,
result: Any,
onOversize: (String) -> Unit = {},
): ToolResultBlockParam {
val builder = ToolResultBlockParam.builder().toolUseId(toolUseId)
if (isError) builder.isError(true)
val carried = (result as? CarriesToolImages)?.toolImages.orEmpty()
.filter { it.dataUrl.isNotBlank() }
if (carried.isEmpty()) return builder.content(resultJson).build()
val blocks = mutableListOf<ToolResultBlockParam.Content.Block>()
blocks += ToolResultBlockParam.Content.Block.ofText(TextBlockParam.builder().text(resultJson).build())
for (image in carried) {
val decoded = decodeDataUrl(image.dataUrl)
if (decoded == null) {
onOversize("dropped a tool image: not a base64 data URL this bridge can send")
continue
}
val (mediaType, data) = decoded
if (data.length > ANTHROPIC_MAX_IMAGE_BASE64_CHARS) {
onOversize(
"dropped a tool image: ${data.length} base64 chars exceeds the " +
"$ANTHROPIC_MAX_IMAGE_BASE64_CHARS limit; the text result was still sent"
)
continue
}
if (image.caption.isNotBlank()) {
blocks += ToolResultBlockParam.Content.Block.ofText(
TextBlockParam.builder().text(image.caption).build()
)
}
blocks += ToolResultBlockParam.Content.Block.ofImage(
ImageBlockParam.builder()
.source(Base64ImageSource.builder().mediaType(mediaType).data(data).build())
.build()
)
}
// Every image was rejected, so there is nothing a block list buys over the plain string.
if (blocks.size == 1) return builder.content(resultJson).build()
return builder.contentOfBlocks(blocks).build()
}
internal fun decodeDataUrl(dataUrl: String): Pair<Base64ImageSource.MediaType, String>?
Split a data:image/png;base64,… URL into the media type the API wants and the payload.
Returns null for anything else — a remote https: URL, a non-image type, a media type Anthropic
does not accept, or a malformed string. ToolImage.dataUrl is documented as a data URL, so a caller
that passes something else is making a mistake this cannot repair by guessing.
internal fun decodeDataUrl(dataUrl: String): Pair<Base64ImageSource.MediaType, String>? {
val match = Regex("""^data:(image/[a-zA-Z0-9.+-]+);base64,(.*)$""", RegexOption.DOT_MATCHES_ALL)
.find(dataUrl.trim()) ?: return null
val (mime, data) = match.destructured
if (data.isBlank()) return null
val mediaType = when (mime.lowercase()) {
"image/png" -> Base64ImageSource.MediaType.IMAGE_PNG
"image/jpeg", "image/jpg" -> Base64ImageSource.MediaType.IMAGE_JPEG
"image/gif" -> Base64ImageSource.MediaType.IMAGE_GIF
"image/webp" -> Base64ImageSource.MediaType.IMAGE_WEBP
else -> return null
}
return mediaType to data
}
internal fun anthropicSystemBlocks(threadInstructions: String, stableInstructions: String, cached: Boolean): List<TextBlockParam>
The system prompt as blocks: the thread's own instructions, then this turn's stable ephemeral ones. With cached, the last block carries a cache breakpoint, so a conversation's every request reads the system prompt (and the tools before it) from cache until a stable instruction actually changes.
internal fun anthropicSystemBlocks(threadInstructions: String, stableInstructions: String, cached: Boolean): List<TextBlockParam> {
val texts = listOf(threadInstructions, stableInstructions).filter { it.isNotBlank() }
return texts.mapIndexed { index, text ->
TextBlockParam.builder().text(text)
.apply { if (cached && index == texts.lastIndex) cacheControl(CacheControlEphemeral.builder().build()) }
.build()
}
}
internal fun withToolCacheBreakpoint(defs: List<Tool>): List<Tool>
The tool list with a cache breakpoint on its last definition: tools come first in the prompt, and rarely change.
internal fun withToolCacheBreakpoint(defs: List<Tool>): List<Tool> =
if (defs.isEmpty()) defs
else defs.dropLast(1) + defs.last().toBuilder().cacheControl(CacheControlEphemeral.builder().build()).build()