AgentToolExtensions.kt
src/jvmMain/kotlin/afield/ai/tools/AgentToolExtensions.kt · 369 lines
package afield.ai.tools
import afield.ai.diagnostics.log
import afield.ai.models.OpenAiIdeCodexBridge
import afield.app.diagnostics.logging.lprintln
import com.fasterxml.jackson.annotation.JsonClassDescription
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.util.concurrent.CopyOnWriteArrayList
The registration point through which code outside the AI core, including a document's own boxes, adds tools to the AI4 agents.
AgentToolExtensions.register takes a request class, an availability check and a handler;
registering the same tool name again in the same scope replaces the handler. A result type that
implements SummarisesForToolLog supplies its own one-line summary for the tool log.
object AgentToolExtensions
How code outside core adds tools to the AI4 agents.
Why this exists
Core used to name a domain cluster directly: registerBuiltInAgentTools called
afield.rect.registerHouseTools(tools), and OpenAiIdeCodexBridge had a when branch on that
cluster's result type. Both worked, and both were the wrong shape — the AI plumbing should not know
what subject matters exist.
The forcing function was moving that cluster into src/personal, which is a separate compilation:
the compiler will not let core reference it, so the reference had to be inverted. That is the right
outcome for a reason beyond the move. Any cluster — in core, in personal, or in a document's own boxes —
now adds tools the same way, and none of them require an edit to core to do it.
Registering
AgentToolExtensions.register(
MyToolRequest::class.java,
available = { ctx -> myThingExists() },
) { _, request -> MyToolResult(...) }
Call it once, from wherever the owning code is first touched — a personal package's init, a
Box helper, an installer. Registration is idempotent per tool name and scope: registering
another class with the same Class.getSimpleName in the same scope replaces the handler rather than
adding a second one, so a re-run of a live-coded box does not accumulate duplicates. The name, not
the Class, is the key on purpose; see "Live coding" below.
Scopes
A tool defined in a document's box should belong to that document. Pass scope (the document's
filename is the natural key) and two open documents can each register a tool of the same name: a
call goes to the most recently registered handler whose available gate passes under the call's
context, so each document's gate — typically "is the conversation in this document?", via
ToolExecutionContext.focus — picks its own handler. Without a scope a registration replaces every
other unscoped registration of that name, as it always has.
The request class is the tool's schema. Annotate it with JsonClassDescription and its fields with
@JsonPropertyDescription; the same reflection that describes a built-in tool describes this one, so
there is nothing extra to write for the OpenAI path or the MCP face.
Core's own tool names are reserved. A request class called Commentary or EditFileRequest is
rejected by register with an error naming the clash, rather than quietly shadowing a built-in the
agent's instructions depend on.
Live coding
A tool registered from a box reaches every open conversation immediately, including ones that were
already running, and re-running the box swaps the handler under them. That is a property of this
object rather than of the bridges: each bridge builds its ToolRegistry in its constructor, and
Ai4Installer builds each bridge lazily on the first AI turn and then caches it for the life of the
window, so a registry is built once and long outlives any given box run.
It did not work that way until 2026-08-11, and the way it failed is worth knowing because it is the shape of failure this seam invites. applyTo copied the registrations into the registry, so:
- a box run after the first AI turn registered a tool no model ever saw; and
- re-running a box replaced the registration here while the agent went on calling the previous handler — compiled into a classloader that no longer corresponded to anything on screen — for the rest of the session.
Neither logged anything. The order that did work, and the only one anybody had tried, was "run the
box, then open the chat". applyTo now subscribes a registry instead of copying into it, and
AgentToolExtensionsTest holds that down from both directions.
What a tool result should implement
Two optional interfaces, both in core so anything can implement them:
- CarriesToolImages — the result's pictures are appended to the conversation as images, which is how a geometry tool lets a model see what it just did.
- SummarisesForToolLog — one line for the tool I/O log. Worth implementing: without it the log records only the class name, and the tool-use improvement loop reads that log.
object AgentToolExtensions {
class Registration<T : Any> internal constructor
One registered tool: its request class, its availability gate, its handler and its scope.
class Registration<T : Any> internal constructor(
val toolClass: Class<T>,
val available: (ToolExecutionContext) -> Boolean,
val handler: (ToolExecutionContext, T) -> Any,
/** Who owns this registration — see "Scopes" above; `null` for an unscoped tool. */
val scope: Any? = null,
/** Namespace, exposure and effects; null = the request class's [AgentToolMeta], or [ToolMeta.DEFAULT]. */
val meta: ToolMeta? = null,
)
private val registrations = CopyOnWriteArrayList<Registration<*>>()
fun registeredNames(): Set<String> = registrations.map { it.toolClass.simpleName }.toSet()
The tool names currently registered here (request class simple names).
Every ToolRegistry applyTo has been called on, held weakly.
Weakly because a registry's lifetime is a bridge's lifetime, and a bridge can be discarded — a
CLI builds one per run, and Ai4Installer would build a fresh one if the provider setting
changed. A strong reference here would pin every bridge, and with it every handler lambda and
everything those close over, for the life of the process.
private val boundRegistries: MutableSet<ToolRegistry> =
java.util.Collections.newSetFromMap(java.util.WeakHashMap())
private fun bound(): List<ToolRegistry> = synchronized(boundRegistries) { boundRegistries.toList() }
fun <T : Any> register
Add a tool, replacing any previous registration of the same name, and push it to every agent that is already running.
available is asked on every turn, so it is the place to gate a cluster's tools on whether that
cluster has anything to work on. Seventeen tool schemas on every request is a real cost for a
conversation that is not about the subject.
Keyed on Class.getSimpleName rather than on the Class object, because the case this exists
for is a box being re-run: a live REPL recompiles into a fresh classloader, so the second run's
request class is a different Class with an identical name. Identity de-duplication left both
registrations in place, and everything downstream — describe, execute, isAvailable, and the
tool name a model sends back — keys on the simple name, so the model was shown the tool twice and
the older handler is the one that ran.
@throws IllegalArgumentException if a core built-in already owns this tool name in a live agent, or if
OpenAI would refuse toolClass's argument schema (see requireOpenAiSchema).
fun <T : Any> register(
toolClass: Class<T>,
available: (ToolExecutionContext) -> Boolean = { true },
scope: Any? = null,
meta: ToolMeta? = null,
handler: (ToolExecutionContext, T) -> Any,
) {
val name = toolClass.simpleName
val live = bound()
if (live.any { !it.acceptsExtensionNamed(name) }) {
throw IllegalArgumentException(
"Cannot register an extension tool named '$name': a built-in AI tool already uses that " +
"name. Rename the request class — the class's simple name is the tool name the " +
"model sees, so it has to be unique across every tool in the conversation."
)
}
requireOpenAiSchema(toolClass)
registrations.removeAll { it.toolClass.simpleName == name && it.scope == scope }
val registration = Registration(toolClass, available, handler, scope, meta)
registrations.add(registration)
publish(name, live, "register")
// Registered from a running box: the run owns it, like an aiTool — a re-run replaces it, a re-run that
// stops registering it drops it, deleting the box retires it. Released by identity, so a newer
// registration of the same name is never the one removed. Registrations from compiled code (a
// personal package's init) stay process-global, as before.
afield.runtime.lifecycle.RunScope.executingBox()?.let { owner ->
afield.runtime.lifecycle.LifecycleLedger.claim(
"agent-tool-extension",
"$name:${System.identityHashCode(scope)}",
slot = afield.runtime.lifecycle.LedgerSlot(registrations, name to scope),
owner = owner,
scope = afield.runtime.lifecycle.RunScope.currentFor(owner),
live = { registrations.any { it === registration } },
policy = afield.runtime.lifecycle.ReleasePolicy.ACCUMULATING,
release = { if (registrations.removeIf { it === registration }) publish(name, bound(), "release") },
)
}
}
The registrations of name, oldest first.
private fun named(name: String) = registrations.filter { it.toolClass.simpleName == name }
The registration of name a call under ctx goes to: the newest whose gate passes.
private fun chosen(name: String, ctx: ToolExecutionContext): Registration<*>? =
named(name).lastOrNull { it.available(ctx) }
Put one entry for name into each of into: the newest registration's request class (its
schema), a gate that passes when any registration's does, and a handler that runs the one
chosen for the call. A request built from one scope's class is converted to the chosen one's,
because a re-run box compiles its request class into a fresh classloader.
private fun publish(name: String, into: List<ToolRegistry>, where: String) {
val newest = named(name).lastOrNull()
if (newest == null) {
into.forEach { it.unregisterExtension(name) }
return
}
@Suppress("UNCHECKED_CAST")
val schema = newest.toolClass as Class<Any>
val gate: (ToolExecutionContext) -> Boolean = { ctx -> chosen(name, ctx) != null }
val handler: (ToolExecutionContext, Any) -> Any = { ctx, request ->
@Suppress("UNCHECKED_CAST")
val target = (chosen(name, ctx) ?: throw IllegalStateException(
"Extension tool '$name' is not available in this context."
)) as Registration<Any>
val typed = if (target.toolClass.isInstance(request)) request else aiToolMapper.convertValue(request, target.toolClass)
target.handler(ctx, typed)
}
val meta = newest.meta ?: ToolMeta.of(schema)
into.forEach { if (!it.registerExtension(schema, gate, meta, handler)) reportRefusal(name, where) }
}
Say out loud that a registration was refused, because the alternative is the failure mode this seam exists to prevent: a tool that is registered on the author's side and invisible to every model, with nothing anywhere to say why.
ToolRegistry.registerExtension returns false on a collision with a core tool name and
documents that "the caller turns that into an error the extension author can read; swallowing it
would leave them with a tool that is registered on their side and invisible to every model".
Neither caller did: register threw on the strength of a separate pre-check and dropped the
authoritative answer, and applyTo dropped it deliberately. So the documented contract had no
enforcing call site anywhere.
Both of those sites are now honest, but be clear about which one carries the weight, because tracing it changed the answer. Neither of these is the path the failure actually takes:
register's refusal is a race only. Its pre-check has already established that no bound registry holds the name, so afalsehere means core claimed it in between. Worth reporting precisely because it should be unreachable.- applyTo's refusal is unreachable for a fresh registry, which is the only kind it gets:
applyToruns at the very top ofregisterBuiltInAgentTools, so the registry is still empty and accepts everything.
The real path is neither, and it is not in this file: the extension is accepted by the empty
registry and then overwritten by core's own register a few calls later, which is documented
behaviour and was silent. That is where the log line that matters lives — see
ToolRegistry.register — and AgentToolExtensions.register could not have caught it, because at
startup bound is empty and there is no collision for it to see.
A log line rather than a throw, at all three sites. The reasoning that kept applyTo silent was right that throwing inside a bridge constructor costs the whole conversation instead of the one bad tool. It was wrong to conclude silence: not throwing and not telling anybody are separable decisions, and only the first was justified.
private fun reportRefusal(name: String, where: String) {
lprintln(
"warning,ai4",
"AgentToolExtensions ($where): the tool named '$name' was refused because a built-in AI " +
"tool already uses that name, so no model will see it. Rename the request class — its " +
"simple name is the tool name the model sees, and it has to be unique across every " +
"tool in the conversation."
)
}
fun unregister(toolClass: Class<*>) = unregister(toolClass.simpleName)
Drop a tool, in every scope. Mostly for tests, and for a live-coded registration being retired.
fun unregister(name: String, scope: Any? = ALL_SCOPES)
Drop a tool by the name the model sees, for a box retiring a registration whose request class it
no longer has in scope — which, after an edit that renamed or deleted the class, is normal. With
scope, only that scope's registration goes; the others keep answering.
fun unregister(name: String, scope: Any? = ALL_SCOPES) {
registrations.removeAll { it.toolClass.simpleName == name && (scope === ALL_SCOPES || it.scope == scope) }
publish(name, bound(), "unregister")
}
Marks an unregister of every scope.
private val ALL_SCOPES = Any()
fun registeredToolNames(): List<String>
Every registered tool's name, once each, for diagnostics and for a status readout.
fun registeredToolNames(): List<String> = registrations.map { it.toolClass.simpleName }.distinct().sorted()
fun isAvailable(ctx: ToolExecutionContext, name: String): Boolean = chosen(name, ctx) != null
True if name is registered here and its gate passes under ctx.
Part of the public face because an extension author otherwise has no way to check that their gate does
what they think — core's own registry is internal, so a tool set living outside core cannot inspect
itself through it. That gap made the house tool set's own smoke untestable after the move.
fun availableToolNames(ctx: ToolExecutionContext): List<String>
The names of every extension tool available under ctx, sorted.
fun availableToolNames(ctx: ToolExecutionContext): List<String> =
registrations.filter { it.available(ctx) }.map { it.toolClass.simpleName }.distinct().sorted()
fun execute(ctx: ToolExecutionContext, name: String, argsJson: String): Any
Runs an extension tool by name with JSON arguments, exactly as a model's tool call would.
The same Jackson deserialization core uses, so a mistake in a request class's shape surfaces here rather than only in a live conversation. Throws if the tool is unknown or its gate is closed — silently returning nothing would make a closed gate look like a working tool.
fun execute(ctx: ToolExecutionContext, name: String, argsJson: String): Any {
if (named(name).isEmpty()) throw IllegalArgumentException("No extension tool registered for: $name")
val registration = chosen(name, ctx)
?: throw IllegalStateException("Extension tool '$name' is not available in this context.")
@Suppress("UNCHECKED_CAST")
val typed = registration as Registration<Any>
val args: Any = aiToolMapper.readValue(argsJson.ifBlank { "{}" }, typed.toolClass)
return typed.handler(ctx, args)
}
fun describe(ctx: ToolExecutionContext): List<String>
MCP-style descriptors for the extension tools available under ctx — {name, description, inputSchema}.
Deliberately the same shape core's own registry emits, so an extension's tools look identical to a built-in's from the outside and anything reading descriptors needs no special case for them.
fun describe(ctx: ToolExecutionContext): List<String> =
registrations.map { it.toolClass.simpleName }.distinct().sorted()
.mapNotNull { chosen(it, ctx) }
.map { registration ->
buildJsonObject {
put("name", registration.toolClass.simpleName)
val description = registration.toolClass.kotlin
.annotations.filterIsInstance<JsonClassDescription>().firstOrNull()?.value
if (!description.isNullOrBlank()) put("description", description)
put("inputSchema", ToolSchema.forClass(registration.toolClass))
}.toString()
}
internal fun applyTo(tools: ToolRegistry)
Applies every registration to tools and subscribes it to later ones. Called by core once, while building the registry.
The subscription is the whole point: a registry is built once per bridge and a bridge outlives every box run in the window, so a copy taken here would be a snapshot of whichever tool sets happened to have loaded before the human's first AI turn. See this object's docs for what that actually looked like in practice.
A refusal from ToolRegistry.registerExtension here is a name collision with a core built-in. It
is logged, not thrown: this runs inside a bridge constructor on whatever thread opened the
chat, so throwing would take out the conversation rather than the one offending tool. It used to be
dropped in silence, which is a different decision and was never argued for.
Do not expect to see it. This runs at the very top of registerBuiltInAgentTools, so the registry
is empty and accepts every registration; a refusal here would mean that invariant has changed. The
collision an extension author actually hits is reported one layer down, by ToolRegistry.register
when core overwrites the name it just accepted. See reportRefusal.
internal fun applyTo(tools: ToolRegistry) {
synchronized(boundRegistries) { boundRegistries.add(tools) }
registrations.map { it.toolClass.simpleName }.distinct().forEach { publish(it, listOf(tools), "applyTo") }
}
}
interface SummarisesForToolLog
A tool result that can describe itself in one line for the tool I/O log.
The log is what AiToolUseImprovementMonitor reads, and a result that does not implement this is
recorded as bare type=MyResult — which cannot distinguish a turn where the model got what it needed
from one where it got an error and gave up. Say what mattered: whether it succeeded, how many issues
came back, the worst of them, and how many images went with it.
Keep it short. This is one line in a log, not a report.
interface SummarisesForToolLog {
fun toolLogSummary(): String
One line for the tool I/O log — see the interface docs for what belongs in it.