ToolPool.kt
src/jvmMain/kotlin/afield/ai/tools/ToolPool.kt · 234 lines
@file:afield.runtime.completion.CompletionCategory("Workspace")
package afield.ai.tools
import afield.document.graph.Box
import afield.app.server.ReadOnlyDocuments
import afield.study.source.BoxStudies
import com.fasterxml.jackson.module.kotlin.readValue
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.time.Instant
import java.util.UUID
The tool pool: a local library, shared across projects, of tools written in box Kotlin, with their tests and the evidence those tests produced.
BoxToolTest attaches an executable example to a Box.aiTool definition, and ToolDiagnostics
keeps bounded per-call evidence (ToolCallEvidence) and suite results (ToolTestEvidence) keyed
by source hash, so an edited tool does not inherit an old pass. ToolPool stores immutable
ToolPoolEntry snapshots under ~/.afield/tool-pool; loading an entry returns its source and
never runs it.
class BoxToolTest(val name: String, val arguments: String, internal val verify: (Any) -> Unit) {
An executable example attached to an ordinary Box.aiTool definition.
internal var failureContains: String?
internal var failureContains: String? = null
private set
internal constructor(name: String, arguments: String, failureContains: String) : this(name, arguments, {})
internal constructor(name: String, arguments: String, failureContains: String) : this(name, arguments, {}) {
this.failureContains = failureContains
}
}
internal suspend fun evaluateToolTest
Evaluate the declared outcome without treating cancellation or unexpected errors as a pass.
internal suspend fun evaluateToolTest(
example: BoxToolTest,
call: suspend () -> Any,
verify: suspend (Any) -> Unit,
): ToolTestOutcome {
val started = System.nanoTime()
fun outcome(passed: Boolean, message: String) = ToolTestOutcome(
example.name, passed, ToolDiagnostics.bounded(message), (System.nanoTime() - started) / 1_000_000,
)
val failure = try {
val result = call()
if (ToolDiagnostics.succeeded(result)) {
if (example.failureContains != null) {
return outcome(false, "Expected a failure containing '${example.failureContains}', but the tool succeeded")
}
verify(result)
return outcome(true, "Passed")
}
ToolDiagnostics.summary(result)
} catch (e: java.util.concurrent.CancellationException) {
throw e
} catch (e: Exception) {
e.message ?: e.javaClass.simpleName
}
val expected = example.failureContains
return when {
expected == null -> outcome(false, failure)
failure.contains(expected) -> outcome(true, "Rejected as expected: $failure")
else -> outcome(false, "Expected a failure containing '$expected'; received: $failure")
}
}
data class ToolTestOutcome(val name: String, val passed: Boolean, val message: String, val elapsedMs: Long)
A test verdict for one exact registered source revision.
data class ToolTestOutcome(val name: String, val passed: Boolean, val message: String, val elapsedMs: Long)
data class ToolCallEvidence(val id: String = UUID.randomUUID().toString(), val at: String = Instant.now().toString(),
Bounded, local diagnostics for a call. Images stay in the existing image/transcript path.
data class ToolCallEvidence(val id: String = UUID.randomUUID().toString(), val at: String = Instant.now().toString(),
val tool: String, val sourceHash: String, val caller: String, val arguments: String,
val success: Boolean, val result: String, val elapsedMs: Long)
data class ToolTestEvidence(val sourceHash: String, val at: String = Instant.now().toString(), val tests: List<ToolTestOutcome>)
A saved suite result; source identity prevents an edited tool inheriting an old green result.
data class ToolTestEvidence(val sourceHash: String, val at: String = Instant.now().toString(), val tests: List<ToolTestOutcome>)
class ToolDiagnostics internal constructor(private val tool: BoxTool)
Diagnostics attached to a registered tool, rather than a second execution engine. Every entry point uses the same recorder. Records live with the document and survive reopening. Capture is bounded and best effort: a full disk must not turn a successful edit into a reported failure.
class ToolDiagnostics internal constructor(private val tool: BoxTool) {
private val recent = ArrayDeque<ToolCallEvidence>()
var storageError: String?
var storageError: String? = null
private set
private fun directory(): File? = BoxStudies.documentOf(tool.box)?.let {
File(File(it.filename, ".tool-evidence"), ToolPool.hash(tool.name) + "/" + tool.sourceHash)
}
internal suspend fun record(arguments: String, caller: String, operation: suspend () -> Any): Any {
internal suspend fun record(arguments: String, caller: String, operation: suspend () -> Any): Any {
val started = System.nanoTime()
try {
val result = operation()
retain(ToolCallEvidence(tool=tool.name, sourceHash=tool.sourceHash, caller=caller,
arguments=bounded(arguments), success=succeeded(result), result=summary(result), elapsedMs=(System.nanoTime()-started)/1_000_000))
return result
} catch (e: Throwable) {
if (e !is VirtualMachineError || e is StackOverflowError) retain(ToolCallEvidence(tool=tool.name, sourceHash=tool.sourceHash, caller=caller,
arguments=bounded(arguments), success=false, result=bounded(afield.ai.conversation.aiToolFailureMessage(e)), elapsedMs=(System.nanoTime()-started)/1_000_000))
throw e
}
}
@Synchronized private fun retain(evidence: ToolCallEvidence) {
recent.addLast(evidence)
while(recent.size > 40) recent.removeFirst()
runCatching {
directory()?.let { dir ->
writeAtomic(File(dir, "call-${evidence.at.replace(':','-')}-${evidence.id}.json"), evidence)
dir.listFiles { f -> f.name.startsWith("call-") }?.sortedByDescending { it.name }?.drop(40)?.forEach { it.delete() }
}
}.onFailure { storageError=it.message ?: it.javaClass.simpleName }
}
fun calls(): List<ToolCallEvidence>
Recent calls for this tool source, including diagnostics retained before a restart.
@Synchronized
fun calls(): List<ToolCallEvidence> {
val disk = directory()?.listFiles { f -> f.name.startsWith("call-") }?.mapNotNull {
runCatching { aiToolMapper.readValue<ToolCallEvidence>(it) }.getOrNull()
}.orEmpty()
return (disk+recent).distinctBy { it.id }.sortedByDescending { it.at }.take(40)
}
internal fun recordTests(outcomes: List<ToolTestOutcome>)
internal fun recordTests(outcomes: List<ToolTestOutcome>) {
lastTests = ToolTestEvidence(tool.sourceHash, tests=outcomes)
runCatching { directory()?.let { writeAtomic(File(it,"tests.json"), lastTests!!) } }
.onFailure { storageError=it.message ?: it.javaClass.simpleName }
}
private var lastTests: ToolTestEvidence? = null
fun tests(): ToolTestEvidence?
Null means untested, never a pass. An empty suite is also untested.
fun tests(): ToolTestEvidence? = lastTests ?: directory()?.let {
runCatching { aiToolMapper.readValue<ToolTestEvidence>(File(it,"tests.json")) }.getOrNull()
}
companion object
internal fun bounded(text: String)
internal fun bounded(text: String) = if(text.length<=8000) text else text.take(8000)+"\n[truncated]"
internal fun summary(value: Any): String
internal fun summary(value: Any): String = bounded(when(value) {
is SummarisesForToolLog -> value.toolLogSummary()
is CarriesToolImages -> "${value.javaClass.simpleName}: ${value.toolImages.size} attached image(s)"
else -> runCatching { aiToolMapper.writeValueAsString(value) }.getOrElse { value.toString() }
})
internal fun succeeded(value: Any): Boolean
internal fun succeeded(value: Any): Boolean {
if(value is BoxToolError) return false
val node = runCatching { aiToolMapper.valueToTree<com.fasterxml.jackson.databind.JsonNode>(value) }.getOrNull() ?: return true
fun populated(n: com.fasterxml.jackson.databind.JsonNode?): Boolean = when {
n == null || n.isNull -> false
n.isBoolean -> n.asBoolean()
n.isTextual -> n.asText().isNotBlank()
n.isContainerNode -> n.size() > 0
else -> false
}
// Inspect only the declared outcome. A successful history/query may contain older
// failures in its payload; those must not turn the query itself into a failure.
return node.get("success")?.asBoolean(true)!=false && node.get("ok")?.asBoolean(true)!=false &&
!populated(node.get("error")) && !populated(node.get("errors")) &&
node.get("status")?.asText()?.lowercase() !in setOf("error", "failed", "held") &&
!(node.get("tested")?.asBoolean() == true && node.get("passed")?.asBoolean() == false)
}
}
}
data class PooledTool(val name: String, val description: String, val schema: String, val tests: List<String>)
Description of one tool supplied by a pool entry. The source remains ordinary box Kotlin.
data class PooledTool(val name: String, val description: String, val schema: String, val tests: List<String>)
data class ToolPoolEntry(val key: String, val revision: String, val description: String, val sourceHash: String,
An immutable, explicit snapshot of a tool-provider box, its attached checks and provenance.
data class ToolPoolEntry(val key: String, val revision: String, val description: String, val sourceHash: String,
val source: String, val tools: List<PooledTool>, val checks: Map<String, ToolTestEvidence>,
val originDocument: String, val originBox: String, val created: String = Instant.now().toString())
class ToolPool(val directory: File = File(System.getProperty("user.home"), ".afield/tool-pool"))
A local, cross-project library of box-authored tools. This stores source; it is not a tool registry. Curating is explicit. Loading an entry never runs it or imports the originating project's state. Run the returned source in a normal provider box to register its tools, then test in that project. Keep project-specific dependencies explicit in the provider source and entry description.
companion object
fun hash(source: String): String
fun hash(source: String): String = MessageDigest.getInstance("SHA-256").digest(source.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
private fun key(key: String) { require(key.matches(Regex("[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}"))) { "Pool key must be a simple name, not a path" } }
}
fun list(query: String = ""): List<Map<String, Any>>
Discover immutable versions without reading executable source into the caller's context.
fun list(query: String = ""): List<Map<String, Any>> = directory.listFiles()?.filter { it.isDirectory }?.flatMap { dir ->
dir.listFiles { f -> f.extension=="json" }?.mapNotNull { file ->
runCatching { read(dir.name,file.nameWithoutExtension) }.getOrNull()
}.orEmpty()
}.orEmpty().filter { (it.key+" "+it.description+" "+it.tools.joinToString { t->t.name }).contains(query,true) }
.sortedByDescending { it.created }.map { mapOf("key" to it.key,"revision" to it.revision,"description" to it.description,
"tools" to it.tools,"checks" to it.checks,"created" to it.created) }
fun read(key: String, revision: String): ToolPoolEntry
Read an exact curated version; modified source is refused.
fun read(key: String, revision: String): ToolPoolEntry {
key(key); require(revision.matches(Regex("[0-9a-f]{64}"))) { "Use an exact pool revision" }
val entry=aiToolMapper.readValue<ToolPoolEntry>(File(directory,"$key/$revision.json"))
require(entry.key==key && entry.revision==revision && hash(entry.source)==entry.sourceHash && identity(entry)==revision) { "Pool entry changed on disk" }
return entry
}
fun publish(box: Box, key: String, description: String): ToolPoolEntry
Explicitly curate this provider's registered source, retaining its exact source-specific checks.
fun publish(box: Box, key: String, description: String): ToolPoolEntry {
check(!ReadOnlyDocuments.blocksWritesFrom(box)) { "This document is read-only" }
val document=checkNotNull(BoxStudies.documentOf(box)) { "Tool provider must belong to a document" }
val tools=DocumentToolRegistry.toolsFor(document.filename).filter { it.boxId==box.id }
require(tools.isNotEmpty()) { "Run a box with aiTool definitions before curating it" }
val source=box.getPropertyRaw("code") as? String ?: ""
require(source.isNotBlank() && tools.all { it.source==source }) { "Provider source changed since registration; run and test it again" }
return store(key,description,source,tools.map { PooledTool(it.name,it.description,it.parametersSchema.toString(),it.tests.map { t->t.name }) },
tools.mapNotNull { t->t.diagnostics.tests()?.let { t.name to it } }.toMap(),document.filename,box.id)
}
internal fun store(key: String, description: String, source: String, tools: List<PooledTool>, checks: Map<String,ToolTestEvidence>, origin: String, box: String)
internal fun store(key: String, description: String, source: String, tools: List<PooledTool>, checks: Map<String,ToolTestEvidence>, origin: String, box: String): ToolPoolEntry {
key(key); require(description.isNotBlank()) { "Describe the tool's purpose and dependencies" }
val candidate=ToolPoolEntry(key,"",description,hash(source),source,tools,checks,origin,box)
val entry=candidate.copy(revision=identity(candidate))
val file=File(directory,"$key/${entry.revision}.json")
if(file.exists()) return read(key,entry.revision)
writeAtomic(file,entry)
return entry
}
private fun identity(entry: ToolPoolEntry) = hash(aiToolMapper.writeValueAsString(entry.copy(revision="",created="")))
}
private fun writeAtomic(file: File, value: Any) {
file.parentFile.mkdirs()
val tmp=Files.createTempFile(file.parentFile.toPath(),".tool-",".tmp")
try {
aiToolMapper.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(),value)
Files.move(tmp,file.toPath(),StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING)
} finally { Files.deleteIfExists(tmp) }
}