ToolMeta.kt
src/jvmMain/kotlin/afield/ai/tools/ToolMeta.kt · 113 lines
package afield.ai.tools
Tool metadata answers three questions beyond the schema: which namespace a tool belongs to, how the
model discovers it (ToolExposure), and what calling it does (ToolEffect). Both of the last two
follow Pi (earendil-works/pi). Exposure decides whether a tool's schema is sent with every request or
only on demand. Field uses effects for three things rather than for confirmation dialogs: a central
refusal of document mutations on a read-only document, eval checks
(effects-absent:FILESYSTEM_WRITE), and the catalogue line that tells a model which calls are cheap
looks and which change things.
Metadata comes from AgentToolMeta on the request class, from aiTool { namespace(); exposure();
effects() } for a box-authored tool, or from AgentToolExtensions.register(meta = ...). A class with
none gets ToolMeta.DEFAULT: namespace core, DIRECT, ToolEffect.UNDECLARED — undeclared rather
than "no effects", so an eval asserting an effect is absent fails on a tool that never said.
enum class ToolExposure
How a model discovers a tool: schema up front, by program, on request, or never.
enum class ToolExposure {
Schema sent with every request; callable directly and from programs.
DIRECT,
Callable from a tool program, listed by name in the catalogue, schema not sent — for tools only worth calling in bulk. Falls back to DIRECT when the conversation has no tool programs.
PROGRAM,
Listed by name in the catalogue (marked *); the schema is sent once the model asks for it with
DescribeToolsRequest, for the rest of the conversation. For tools most conversations never call.
DEFERRED,
Never shown to a model, nor over MCP; callable only by Field itself and the backdoor.
HIDDEN,
}
enum class ToolEffect
enum class ToolEffect {
READ_DOCUMENT,
MUTATE_DOCUMENT,
EXECUTE_CODE,
Starts, stops or rebinds something live: a launched box, a shader pipeline.
CONTROL_RUNTIME,
CAPTURE_IMAGE,
GPU,
FILESYSTEM_READ,
FILESYSTEM_WRITE,
NETWORK,
The agent's own state: scratchpad, checklist, which tool schemas are loaded.
AGENT_STATE,
The tool never said. Distinct from an empty set, which means "no effects".
UNDECLARED,
}
annotation class AgentToolMeta
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class AgentToolMeta(
val namespace: String,
val exposure: ToolExposure = ToolExposure.DIRECT,
val effects: Array<ToolEffect> = [ToolEffect.UNDECLARED],
)
data class ToolMeta
data class ToolMeta(
val namespace: String = "core",
val exposure: ToolExposure = ToolExposure.DIRECT,
val effects: Set<ToolEffect> = setOf(ToolEffect.UNDECLARED),
) {
val mutates: Boolean get() = ToolEffect.MUTATE_DOCUMENT in effects
val readOnly: Boolean
Only looks: every effect is a read or a picture. What MCP calls readOnlyHint.
val readOnly: Boolean
get() = ToolEffect.UNDECLARED !in effects &&
effects.all { it == ToolEffect.READ_DOCUMENT || it == ToolEffect.FILESYSTEM_READ || it == ToolEffect.CAPTURE_IMAGE }
companion object
val DEFAULT = ToolMeta()
fun of(cls: Class<*>): ToolMeta
fun of(cls: Class<*>): ToolMeta =
cls.getAnnotation(AgentToolMeta::class.java)?.let { ToolMeta(it.namespace, it.exposure, it.effects.toSet()) } ?: DEFAULT
}
}
enum class CallOrigin { MODEL, PROGRAM, BACKDOOR }
Who is calling: the model directly, a tool program, or Field's backdoor / MCP face.
enum class ToolFace { MODEL, MCP, PROGRAM }
Which descriptor list ToolRegistry.describe builds: what a model is shown, or what MCP clients are.
data class DescribeToolsRequest
@com.fasterxml.jackson.annotation.JsonClassDescription(
"Load the full schemas of catalogue tools marked * (deferred): give a namespace or names. Returns each tool's " +
"description, input schema and effects, and makes them callable as ordinary tools for the rest of this conversation. " +
"Use it only when you need a catalogue tool marked * that you cannot call yet; direct tools need no loading. " +
"If nothing matches it fails with success=false and lists the loadable names. e.g. namespace = `macros`."
)
@AgentToolMeta("core", effects = [ToolEffect.AGENT_STATE])
data class DescribeToolsRequest(
@com.fasterxml.jackson.annotation.JsonPropertyDescription("A namespace from the tool catalogue, such as `macros`; blank to use `names` instead.")
val namespace: String = "",
@com.fasterxml.jackson.annotation.JsonPropertyDescription("Tool names from the catalogue, such as MacroRunRequest.")
val names: List<String> = emptyList(),
)
data class DescribeToolsResult
data class DescribeToolsResult(
val success: Boolean,
val message: String,
val tools: List<DescribedTool>,
) : SummarisesForToolLog {
override fun toolLogSummary(): String
override fun toolLogSummary(): String = "success=$success loaded=${tools.joinToString(",") { it.name }}"
}
data class DescribedTool(val name: String, val namespace: String, val description: String, val effects: List<String>, val inputSchema: String)
data class DescribedTool(val name: String, val namespace: String, val description: String, val effects: List<String>, val inputSchema: String)