Skip to content

ToolEvidence.kt

src/jvmMain/kotlin/afield/ai/tools/program/ToolEvidence.kt · 114 lines

package afield.ai.tools.program

import com.fasterxml.jackson.annotation.JsonClassDescription
import com.fasterxml.jackson.annotation.JsonPropertyDescription
import java.io.File
import java.nio.file.Files
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream

Complete tool results that are too large for a transcript, kept whole outside the model's context.

A program's nested calls are recorded in the turn's transcript and the tool I/O log, but a result of tens of kilobytes would make both unreadable, and cutting it at a fixed length leaves invalid JSON that hides exactly the part of a failure the next agent needs. So a nested result over ToolProgramRunner.MAX_NESTED_RESULT_CHARS is stored here whole, content-addressed, and the transcript carries a small, valid stub with its id, SHA-256 and size; ReadToolEvidenceRequest reads it back.

store is replaceable: a host with its own evidence store (a study's build records, say) can put these there instead. The default keeps them gzipped beside the tool I/O log, ~/.afield/logs/ai4_tool_io/evidence/ (AFIELD_AI4_TOOL_EVIDENCE_PATH or -Dafield.ai4.toolEvidence.path to move it). Nothing deletes them yet.

object ToolEvidence

The evidence store for oversized nested tool results: store puts and gets them by content id.

object ToolEvidence {

data class Ref(val id: String, val sha256: String, val bytes: Int)

Where a result was put: id is what ReadToolEvidenceRequest takes; sha256 and bytes are of the UTF-8 text.

interface Store

fun put(text: String): Ref

fun get(id: String): String?

class FileStore(private val dir: File) : Store

gzip files named by content hash in dir.

override fun put(text: String): Ref

        override fun put(text: String): Ref {
            val bytes = text.toByteArray(Charsets.UTF_8)
            val sha = sha256(bytes)
            val id = ID_PREFIX + sha.take(24)
            val target = File(dir.apply { mkdirs() }, "${sha.take(24)}.json.gz")
            if (!target.exists()) {
                val tmp = File.createTempFile("evidence-", ".tmp", dir)
                try {
                    GZIPOutputStream(tmp.outputStream().buffered()).use { it.write(bytes) }
                    Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING)
                } finally { tmp.delete() }
            }
            return Ref(id, sha, bytes.size)
        }

override fun get(id: String): String?

        override fun get(id: String): String? {
            val key = id.removePrefix(ID_PREFIX).takeIf { it.matches(Regex("[0-9a-f]{24}")) } ?: return null
            val file = File(dir, "$key.json.gz").takeIf { it.isFile } ?: return null
            return GZIPInputStream(file.inputStream().buffered()).use { String(it.readBytes(), Charsets.UTF_8) }
        }
    }

const val ID_PREFIX = "ai4ev:"

    private fun defaultDir(): File {
        val raw = System.getProperty("afield.ai4.toolEvidence.path") ?: System.getenv("AFIELD_AI4_TOOL_EVIDENCE_PATH")
        return raw?.trim()?.takeIf { it.isNotEmpty() }?.let { File(it) }
            ?: Paths.get(System.getProperty("user.home"), ".afield", "logs", "ai4_tool_io", "evidence").toFile()
    }

var store: Store

    @Volatile
    var store: Store = FileStore(defaultDir())

fun sha256(bytes: ByteArray): String

    fun sha256(bytes: ByteArray): String =
        MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
}

data class ReadToolEvidenceRequest

@JsonClassDescription(
    "Read back a complete tool result that was too large to keep in the conversation: a tool program's call " +
        "record names it as `evidence` (an id starting ai4ev:). Returns the text from `offset`, at most `limit` " +
        "characters, and whether there is more. Use it only when a call record carries an evidence id and you need the full result. " +
        "An id not in the evidence store fails with success=false. e.g. id = `ai4ev:3f2a`, offset = 16000."
)
@afield.ai.tools.AgentToolMeta("program", exposure = afield.ai.tools.ToolExposure.DEFERRED, effects = [afield.ai.tools.ToolEffect.FILESYSTEM_READ])
data class ReadToolEvidenceRequest(
    @JsonPropertyDescription("The evidence id, ai4ev:…")
    val id: String,
    @JsonPropertyDescription("Character offset to start at; default 0.")
    val offset: Int = 0,
    @JsonPropertyDescription("Most characters to return; default 16000, at most 64000.")
    val limit: Int = 16_000,
)

data class ReadToolEvidenceResult

data class ReadToolEvidenceResult(
    val success: Boolean,
    val id: String,
    val bytes: Int = 0,
    val sha256: String = "",
    val offset: Int = 0,
    val text: String = "",
    val more: Boolean = false,
    val message: String = "",
)

internal fun readToolEvidence(request: ReadToolEvidenceRequest): ReadToolEvidenceResult

internal fun readToolEvidence(request: ReadToolEvidenceRequest): ReadToolEvidenceResult {
    val text = ToolEvidence.store.get(request.id.trim())
        ?: return ReadToolEvidenceResult(false, request.id, message = "No evidence with id ${request.id}.")
    val from = request.offset.coerceIn(0, text.length)
    val limit = (if (request.limit <= 0) 16_000 else request.limit).coerceAtMost(64_000)
    val to = (from + limit).coerceAtMost(text.length)
    val bytes = text.toByteArray(Charsets.UTF_8)
    return ReadToolEvidenceResult(true, request.id, bytes.size, ToolEvidence.sha256(bytes), from, text.substring(from, to), to < text.length)
}