Skip to content

AgentTaskTranscript.kt

src/jvmMain/kotlin/afield/ai/agent/AgentTaskTranscript.kt · 102 lines

package afield.ai.agent

import afield.ai.conversation.AiChatModelIdentity
import afield.document.graph.Box
import afield.app.protocol.Message
import afield.app.server.SessionInformation
import afield.app.protocol.Set_output
import afield.app.protocol._send
import afield.ai.conversation.AiBoxConversation
import afield.ui.output.displayOpen
import afield.app.protocol.payload

Writes an agent task into a box's AI4 chat transcript, so a finished task can be read and continued in that box's chat panel.

object AgentTaskTranscript

Writes an agent task into a box's AI4 chat transcript (ai4_chat_history), in the transcript's own markup (<<you>>, <<ai>>, <<progress>>, <<model:…>>), and pushes it to the box's ai-chat panel. The panel then renders the task like any other turn, and the person can continue the conversation from there.

A live task needs only appendVerdict: the real ai-assist handler already wrote the turn. A scripted task has no handler turn, so appendScriptedTurn writes the whole exchange, with each scripted tool call as a progress entry, the way a live turn's tool calls appear.

object AgentTaskTranscript {

val SCRIPTED_IDENTITY

    val SCRIPTED_IDENTITY = AiChatModelIdentity("scripted", "no model", responseModel = "scripted replay")

fun AgentCheck.spec(): String

    fun AgentCheck.spec(): String = when (kind) {
        "connected" -> "$kind:$parent:$child"
        "output-contains" -> "$kind:$box:$text"
        "output-ints-include" -> "$kind:$box:${ints.joinToString(",")}"
        "property-contains", "property-lacks" -> "$kind:$box:$property:$text"
        "output-colour" -> "$kind:$box:$text:$fraction"
        "output-rising" -> "$kind:$box:$text:${fraction.toInt()}"
        else -> "$kind:$box"
    }

    private fun AgentCheck.subject(): String = if (kind == "connected") "$parent→$child" else box

fun verdictBlock(result: AgentTaskResult): String

The verdict block: a one-line summary (✓ box-exists:Primes ✓ … — PASS), then one line per check with its detail.

    fun verdictBlock(result: AgentTaskResult): String = buildString {
        append("<<progress>>\n")
        // The panel shows a progress entry's first line and folds the rest, so the first line carries
        // the whole verdict: every check's mark and name, then the outcome.
        append(result.checks.joinToString("  ") { (if (it.passed) "✓ " else "✗ ") + it.check.kind + ":" + it.check.subject() })
        append(if (result.checks.isEmpty()) "No checks — ${result.verdict.uppercase()}" else " — ${result.verdict.uppercase()}")
        if (result.mode == "scripted") append(" (scripted replay, no model was called)")
        append("\n")
        if (result.error.isNotBlank()) append("error: ${result.error.take(600)}\n")
        result.checks.forEach { c ->
            append(if (c.passed) "✓ " else "✗ ")
            append(c.check.spec())
            append(" — ")
            append(c.detail.lineSequence().first().take(200))
            append("\n")
        }
    }

fun appendVerdict(info: SessionInformation, box: Box, result: AgentTaskResult)

    fun appendVerdict(info: SessionInformation, box: Box, result: AgentTaskResult) =
        write(info, box, AiBoxConversation.read(box).withTrailingNewline() + verdictBlock(result))

fun appendScriptedTurn(info: SessionInformation, box: Box, goal: String, result: AgentTaskResult)

    fun appendScriptedTurn(info: SessionInformation, box: Box, goal: String, result: AgentTaskResult) {
        val turn = buildString {
            append("<<you>>\n").append(goal.trim()).append("\n")
            append("<<ai>>\n").append(SCRIPTED_IDENTITY.marker())
            append("<<progress>>\nScripted replay — no model was called\n")
            result.setupCalls.forEach { call -> append(progressFor(call, "Setup: ")) }
            result.toolCalls.forEach { call -> append(progressFor(call, "")) }
            append("<<ai>>\n").append(SCRIPTED_IDENTITY.marker())
            append(result.finalText.ifBlank { "(the script gave no final text)" }.trim()).append("\n")
        }
        write(info, box, AiBoxConversation.read(box).withTrailingNewline() + turn + verdictBlock(result))
    }

fun appendError(info: SessionInformation, box: Box, goal: String, message: String)

A task that could not start: the person sees why in the panel, not only in a return value.

    fun appendError(info: SessionInformation, box: Box, goal: String, message: String) {
        val turn = "<<you>>\n${goal.trim()}\n<<progress>>\nAgent task not started\n$message\n"
        write(info, box, AiBoxConversation.read(box).withTrailingNewline() + turn)
    }

    private fun progressFor(call: AgentToolCallRecord, prefix: String): String = buildString {
        append("<<progress>>\n")
        append(prefix).append(call.tool).append(if (call.ok) " finished" else " failed").append("\n")
        append(call.arguments.take(400)).append("\n")
        if (!call.ok) {
            val message = runCatching { afield.ai.tools.aiToolMapper.readTree(call.result).get("message")?.asText() }.getOrNull()
            if (!message.isNullOrBlank()) append(message.take(400)).append("\n")
        }
    }

    private fun String.withTrailingNewline() = if (isEmpty() || endsWith("\n")) this else this + "\n"

    private fun write(info: SessionInformation, box: Box, transcript: String) {
        AiBoxConversation.write(box, transcript)
        _send(Message("set-output").payload(Set_output(box.id, "outputarea", "ai-chat", transcript)), document = Regex.escape(info.filename))
        runCatching { box.displayOpen("ai-chat") }
    }
}