package net.kentonc.si.agent import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put /** * OpenAI-compatible wire types, mirroring src/llm.rs in the Rust original: * messages carry optional content (plain text or a parts list) plus tool * calls, and tool results come back as separate `tool` role messages. */ @Serializable data class WireMessage( val role: String, val content: JsonElement? = null, @SerialName("tool_calls") val toolCalls: List = emptyList(), @SerialName("tool_call_id") val toolCallId: String? = null, ) { companion object { fun system(text: String) = WireMessage(role = "system", content = JsonPrimitive(text)) fun user(text: String, images: List): WireMessage { val content: JsonElement = if (images.isEmpty()) JsonPrimitive(text) else buildJsonArray { if (text.isNotEmpty()) add(buildJsonObject { put("type", "text"); put("text", text) }) images.forEach { img -> add(img.toPart()) } } return WireMessage(role = "user", content = content) } fun assistant(content: String?, toolCalls: List) = WireMessage( role = "assistant", content = content?.takeIf { it.isNotEmpty() }?.let { JsonPrimitive(it) }, toolCalls = toolCalls, ) fun toolResult(id: String, text: String) = WireMessage( role = "tool", content = JsonPrimitive(text), toolCallId = id, ) } } @Serializable data class ToolCall( val id: String = "", @SerialName("type") val type: String = "function", val function: FunctionCall, ) @Serializable data class FunctionCall( val name: String, val arguments: String = "", ) /** One-line rendering of a call, for showing the user what's happening (ToolCall::summary). */ fun ToolCall.summary(): String { val args: JsonObject = runCatching { json.parseToJsonElement(function.arguments.ifBlank { "{}" }) }.getOrNull() as? JsonObject ?: JsonObject(emptyMap()) val parts = args.entries.map { (key, value) -> val text = when (value) { is JsonPrimitive -> if (key == "command" || key == "content") value.content else truncateFor(key, value.content) else -> truncateFor(key, value.toString()) } "$key=$text" } return "${function.name}(${parts.joinToString(", ")})" } private fun truncateFor(key: String, text: String) = if (text.length <= 80) text else text.take(80) + "…" /** Streamed chunk shapes. `content` stays a raw JSON element because servers emit * either a string or an array of typed parts — handle both in [flattenContent]. */ @Serializable data class StreamChunk(val choices: List = emptyList()) @Serializable data class StreamChoice(val delta: StreamDelta? = null) @Serializable data class StreamDelta( val content: JsonElement? = null, @SerialName("tool_calls") val toolCalls: List = emptyList(), ) @Serializable data class ToolCallDelta( val index: Int = 0, val id: String? = null, val function: DeltaFunction? = null, ) @Serializable data class DeltaFunction(val name: String? = null, val arguments: String? = null) /** Flatten a delta `content` field, string or parts list, to the text inside. */ fun flattenContent(el: JsonElement?): String? = when (el) { null, JsonNull -> null is JsonPrimitive -> el.content is JsonArray -> el.joinToString("") { part -> ((part as? JsonObject)?.get("text") as? JsonPrimitive)?.content ?: "" } is JsonObject -> ((el["text"]) as? JsonPrimitive)?.content } /** Something surfaced to the UI mid-turn, one stream like the Rust Item enum. */ sealed interface Item { data class Text(val delta: String) : Item data class ToolStart(val name: String, val summary: String) : Item data class ToolDone(val outcome: String) : Item } /** One local image to attach to the prompt (mirrors Image in llm.rs). */ data class AgentImage(val mime: String, val data: ByteArray) { /** data:;base64, for the image_url part. */ fun dataUrl(): String = "data:$mime;base64," + android.util.Base64.encodeToString(data, android.util.Base64.NO_WRAP) fun toPart() = buildJsonObject { put("type", "image_url") put("image_url", buildJsonObject { put("url", dataUrl()) }) } override fun equals(other: Any?) = other is AgentImage && other.mime == mime && other.data.contentEquals(data) override fun hashCode() = mime.hashCode() * 31 + data.contentHashCode() } internal val json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true encodeDefaults = false }