Skip to content

AgentToolDemoEvidence.kt

src/jvmMain/kotlin/afield/examples/AgentToolDemoEvidence.kt · 188 lines

package afield.examples

import afield.ai.tools.AgentToolExtensions
import afield.ai.tools.SummarisesForToolLog
import afield.ai.tools.ToolExecutionContext
import afield.ai.tools.CarriesToolImages
import afield.graphics.render.GeometryAgentTools
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import java.io.File
import java.nio.file.Files
import java.util.Base64

FIELD_TEST id: ai.tool-extensions.demo runner: jvm-main entrypoint: afield.examples.AgentToolDemoEvidence tags: [smoke, examples, ai4, tools, docs] timeoutSec: 240 expect: exitCode: 0 stdoutContains: - "agent tool demo evidence ok"

object AgentToolDemoEvidence

Drives GeometryAgentTools end to end with no language model, and writes down exactly what a model would have been shown — which is the evidence behind public/docs/aiFeatures.md's "Give the AI a tool of your own".

Why the evidence is committed

A docs page that says "the model sees a schema derived from your request class" is asking to be believed. The committed artefacts are the alternative: the schema is the literal output of AgentToolExtensions.describe, the result JSON is the literal function-call output, and the PNG is the literal image the bridge lifts onto the conversation. GeometryAgentToolsTest reads the schema file back and fails if it has drifted, so the page cannot quietly stop being true.

Two modes, and why

  • With a directory argument (./gradlew runAgentToolDemo) it writes the artefacts there. That is the deliberate regeneration path; commit the diff.
  • With no argument it writes to a temp directory and compares against the committed copies, failing if they differ. That is the mode the ai.tool-extensions.demo FIELD_TEST runs in, so a check never writes into the repository — see "tests: no committed writes".

What it inspects

public/docs/assets/fabricate_demo/ToyCar.glb, the Khronos sample already committed for fabricate_renderer.md. Deliberately a real artist's model rather than a generated sphere: it is 108,936 triangles across three material buckets, it is not watertight, and it is in metres — all three of which are the things a model gets wrong about geometry, and all three of which the tool has to communicate for the example to be worth copying.

object AgentToolDemoEvidence {

    private val mapper: ObjectMapper = ObjectMapper()
        .registerKotlinModule()
        .enable(SerializationFeature.INDENT_OUTPUT)

    private const val COMMITTED = "public/docs/assets/ai_tool_demo"
    private const val SAMPLE_ROOT = "public/docs/assets/fabricate_demo"
    private const val SAMPLE = "ToyCar.glb"

fun main(args: Array<String>)

body — 121 lines, from line 67
    @JvmStatic
    fun main(args: Array<String>) {
        val writing = args.firstOrNull()?.let(::File)
        val out = writing ?: Files.createTempDirectory("afield-ai-tool-demo").toFile()
        out.mkdirs()

        val root = File(SAMPLE_ROOT)
        require(File(root, SAMPLE).isFile) {
            "expected the committed sample at ${File(root, SAMPLE).absolutePath} — run from the repository root"
        }

        GeometryAgentTools.clearSearchRoots()
        GeometryAgentTools.addSearchRoot(root)
        GeometryAgentTools.register()
        val ctx = ToolExecutionContext()

        // The tools report the absolute path of every search root, which is right in a conversation and
        // wrong in a committed file: it would pin this evidence to one machine and one worktree, and
        // the drift check would fail for everyone else on the first run. Replaced with a stable token.
        val repoRoot = File(".").canonicalPath
        fun redact(text: String): String = text.replace(repoRoot, "<repo>")

        val transcript = StringBuilder()
        fun say(line: String) {
            val clean = redact(line)
            println(clean)
            transcript.appendLine(clean)
        }

        say("# A geometry tool call: the schema, the JSON and the picture")
        say("")
        say("Generated by afield.examples.AgentToolDemoEvidence. No language model was involved: every")
        say("call below goes through AgentToolExtensions.execute(ctx, name, argsJson), which is the same")
        say("entry point the MCP backdoor uses and one Jackson call away from the OpenAI bridge's.")
        say("")

        // ---- 1. the tools the model is offered ------------------------------------------------
        val descriptors = AgentToolExtensions.describe(ctx)
            .filter { """"ListGeometryFiles"""" in it || """"InspectGeometryFile"""" in it }
            .sorted()
        check(descriptors.size == 2) { "expected 2 geometry tool descriptors, got ${descriptors.size}" }

        val schemasJson = buildString {
            appendLine("{")
            appendLine("  \"tools\": [")
            descriptors.forEachIndexed { index, descriptor ->
                append("    ").append(descriptor)
                appendLine(if (index == descriptors.lastIndex) "" else ",")
            }
            appendLine("  ]")
            append("}")
        }
        write(out, "geometry_tool_schemas.json", redact(schemasJson).toByteArray())
        say("## The two tool descriptors, verbatim from AgentToolExtensions.describe()")
        say("")
        descriptors.forEach { say(it) }
        say("")

        // ---- 2. the listing call --------------------------------------------------------------
        say("## ListGeometryFiles {}")
        val listing = AgentToolExtensions.execute(ctx, "ListGeometryFiles", "{}")
        say(mapper.writeValueAsString(listing))  // say() redacts
        say("tool log: " + (listing as SummarisesForToolLog).toolLogSummary())
        say("")

        // ---- 3. the inspect call --------------------------------------------------------------
        val argsJson = """{"path": "$SAMPLE", "yawDegrees": 34.0, "pitchDegrees": 22.0}"""
        say("## InspectGeometryFile $argsJson")
        val report = AgentToolExtensions.execute(ctx, "InspectGeometryFile", argsJson)
        val resultJson = redact(mapper.writeValueAsString(report))
        say(resultJson)
        say("tool log: " + (report as SummarisesForToolLog).toolLogSummary())
        say("")
        write(out, "toycar_inspect_result.json", resultJson.toByteArray())

        val images = (report as CarriesToolImages).toolImages
        check(images.size == 1) { "expected one carried image, got ${images.size}" }
        val png = Base64.getDecoder().decode(images.single().dataUrl.removePrefix("data:image/png;base64,"))
        check(png.size > 10_000) { "the render is suspiciously small: ${png.size} bytes" }
        write(out, "toycar_inspect.png", png)

        say("## The image the model is handed, separately from the JSON above")
        say("")
        say("caption: ${images.single().caption}")
        say("bytes:   ${png.size} (toycar_inspect.png)")
        say("")
        say("The JSON carries no data URL and no `toolImages` key. CarriesToolImages annotates")
        say("that property @get:JsonIgnore, so the picture travels as an image message rather than as")
        say("base64 spent as text tokens in a function result a model cannot see pictures inside.")
        check(!resultJson.contains("base64")) { "the data URL leaked into the function-output JSON" }
        check(!resultJson.contains("toolImages")) { "the toolImages key leaked into the function-output JSON" }

        write(out, "session.md", transcript.toString().toByteArray())

        GeometryAgentTools.unregister()
        GeometryAgentTools.clearSearchRoots()

        val names = listOf(
            "geometry_tool_schemas.json", "toycar_inspect_result.json", "toycar_inspect.png", "session.md",
        )
        if (writing != null) {
            println("wrote ${names.size} artefact(s) to ${out.absolutePath}")
        } else {
            val drifted = names.filter { name ->
                val committed = File(COMMITTED, name)
                !committed.isFile || !committed.readBytes().contentEquals(File(out, name).readBytes())
            }
            check(drifted.isEmpty()) {
                "committed evidence under $COMMITTED has drifted from what the code produces: " +
                    "${drifted.joinToString()}. Regenerate with ./gradlew runAgentToolDemo and commit."
            }
            out.deleteRecursively()
            println("checked ${names.size} committed artefact(s) against a fresh run — identical")
        }
        println("agent tool demo evidence ok")
    }

    private fun write(dir: File, name: String, bytes: ByteArray) {
        File(dir, name).writeBytes(bytes)
    }
}