Tools and Registry
In ai4j-agent, the tool system does not really solve "how to expose functions to the model". It solves how to separate four boundaries:
- Which tools the model can see
- Which tools the host is allowed to execute
- Which layer is responsible for structural validation, and which for approval and interception
- How tool results re-enter the Agent loop
If these four concerns are not split apart, the usual outcome is:
- Schema exposure and permission approval get tangled together
- Different runtimes each reinvent their own tool governance
- There is no unified semantics for how to continue reasoning after a tool failure
The design choices in ai4j-agent are deliberate:
AgentToolRegistryowns the exposure surfaceToolExecutorowns the execution surfaceBaseAgentRuntimeowns pulling tool calls into the main loop
1. The shortest object graph: see the roles first
Source entry points:
ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolRegistry.javaai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/ToolExecutor.javaai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentBuilder.javaai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/BaseAgentRuntime.java
The minimal object relationship compresses into a single chain:
AgentBuilder
-> AgentToolRegistry
-> ToolExecutor
-> AgentContext
-> BaseAgentRuntime.runInternal()
-> AgentToolCallSanitizer
-> ToolExecutor.execute(call)
-> AgentMemory.addToolOutput(callId, output)
This split matters because it stops "the visible tool surface" and "actual execution authority" from being an implicit side effect of the same object.
2. AgentToolRegistry does one thing: hand schema to the model
The AgentToolRegistry interface is narrow:
public interface AgentToolRegistry {
List<Object> getTools();
}
It does not execute tools, and it does not decide permissions. It answers only:
- Which tool schemas the current Agent intends to pass to the model
Common implementations include:
StaticToolRegistryCompositeToolRegistryToolUtilRegistryAgentTeamToolRegistry
The core design principle here is: what the model sees is not necessarily what the system will allow to execute.
3. ToolExecutor is the execution boundary, and the permission boundary
ToolExecutor is equally narrow:
public interface ToolExecutor {
String execute(AgentToolCall call) throws Exception;
}
It answers a different question:
- When the model actually issues a tool call, how does the system execute it
This is also the best place to apply the following governance:
- Permission approval
- allow-list / deny-list
- Argument rewriting
- Audit logging
- Remote proxy
- Sandbox execution
- Retry and timeout control
If you need to explain "how ai4j intercepts tool permission approval", the most accurate answer is:
- The stable interception point for an ordinary Agent tool chain is
ToolExecutor.execute(...) - Not
AgentToolCallSanitizer - And not some generic hook
3.1 The multi-tenant filter for RAG-as-tool must be fixed on the server side
If you expose knowledge base retrieval as a RagTool, the tool schema visible to the model should contain only the user query, e.g.:
{"query": "user question"}
Filters such as tenant, department, project, and permission scope should not go into the tool input schema for the model to pass itself:
{"query": "user question", "filter": {"tenant": "tenant_a"}}
The correct approach is to bind them when constructing the tool on the host side:
Map<String, Object> tenantFilter = new HashMap<String, Object>();
tenantFilter.put("tenant", tenantIdFromSession);
RagTool ragTool = RagTool.builder(ragService)
.dataset("shared-kb")
.embeddingModel("text-embedding")
.topK(5)
.filter(tenantFilter)
.build();
This way the LLM only produces the retrieval query; the real tenant boundary is written into RagQuery.filter by the server-side executor. Like ordinary tool permissions, this belongs on the execution boundary, not in parameters the model can negotiate.
4. The default Builder wiring is more concrete than it looks
The default logic in AgentBuilder.build() is not "automagically wire the tools for you". It is an explicit decision chain.
4.1 You did not pass toolRegistry(...)
The default goes through:
StaticToolRegistry.empty()
This means the model sees no tools at all.
4.2 You used the convenience method toolRegistry(List<String> functions, List<String> mcpServices)
The Builder will create:
ToolUtilRegistry
Behind that, ToolUtilRegistry merges through ToolUtil.getAllTools(functionList, mcpServerIds):
- Local function tools
- MCP service tools
In other words, MCP here is not "a sub-page problem of tools"; it is one source feeding the unified tool exposure surface.
4.3 You did not explicitly pass toolExecutor(...)
The Builder first extracts tool names from the "base registry", then tries to create a ToolUtilExecutor.
This step has two important consequences:
- The default executor only allows executing tool names the registry has already exposed
- If the base registry cannot resolve tool names, the default executor may be
null
The second point is a common trap. resolveToolNames(...) only pulls names out of Tool-typed objects; if you supply custom schema objects and do not pass your own ToolExecutor, the Builder will not magically know how to execute them.
4.4 How subagents get wired in
Once a subagent registry is configured, the Builder will:
- Use
CompositeToolRegistryto merge the original tool surface with the subagent tool surface - Use
SubAgentToolExecutorto wrap the original executor
This shows that subagent governance is not an exception at the registry layer; it is a specialized wrapper on the executor side.
5. How the runtime consumes tool calls
The main chain lives in BaseAgentRuntime.runInternal().
5.1 The model returns toolCalls first
The model client folds the response into AgentModelResult, which may contain:
outputTextmemoryItemstoolCalls
5.2 The runtime normalizes first
normalizeToolCalls(...) fills in any missing callId; the default format is:
tool_step_<step>_<index>
The point of this step is not cosmetics. It is to guarantee a stable reference when tool results are written back to memory.
5.3 Then structural validation
AgentToolCallSanitizer.validationError(...) only validates structural legality, e.g.:
- The tool name must not be empty
argumentsmust be a JSON object- The required fields for the different
bashactions exist read_file.pathis not emptyapply_patch.patchis not empty
It answers "does this look like an executable call", not "are you allowed to execute it".
5.4 Then it enters the executor
Calls that pass validation all converge on:
toolExecutor.execute(call)
A tool exception does not bring down the whole Agent by default. BaseAgentRuntime.executeTool(...) wraps the exception into:
TOOL_ERROR: {"errorType":"...","error":"...","tool":"...","callId":"..."}
By default it carries no stack trace. This error result is then fed back to memory and the subsequent turns. This is the classic "model-recoverable failure semantics".
5.5 Finally it re-enters memory
Whether it is a normal result or a wrapped error, both call:
memory.addToolOutput(callId, output)
Tool execution is therefore not an outer-loop side action; it is part of the loop itself.
6. Where approval, interception, and hooks really belong
This is the easiest place to write things ambiguously.
6.1 The correct interception point for an ordinary Agent
If you want to do approval or access control, prefer wrapping it around the executor:
ToolExecutor guarded = call -> {
approvalService.check(call.getName(), call.getArguments());
auditService.record(call);
return delegate.execute(call);
};
The advantages of this layer are very direct:
- You already hold the normalized tool name
- You already hold the structurally valid argument JSON
- You are still inside the unified Agent loop, so errors, audit, trace, and memory write-back do not get distorted
6.2 Why not put it in AgentToolCallSanitizer
Because the sanitizer's responsibility is too narrow. Pushing business authorization logic into it leads to:
- Structural errors and permission denials getting conflated into one kind of error
- Different executors being unable to share the same authorization logic
- Misplaced layering, making it harder to extend later
6.3 Why not rely on a generic hook
Today ai4j-agent does not provide a unified "tool approval hook" abstraction for ordinary Agents.
The places that do have approval/policy concepts are:
- The Team layer's
planApprovalandhooks - The SubAgent layer's
HandoffPolicy
For an ordinary Agent, tool governance is fundamentally an executor-wrapping problem.
7. What SubAgentToolExecutor and AgentTeamToolExecutor illustrate
These two classes capture the ai4j tool design philosophy very well.
7.1 SubAgentToolExecutor
It does not shove handoff logic into the registry. Instead it applies stronger governance at execution time, e.g.:
allowedToolsdeniedToolsmaxDepthtimeoutMillisinputFilteronDeniedonError
In other words, subagent does not overturn the "registry owns exposure, executor owns governance" boundary.
7.2 AgentTeamToolExecutor
It only intercepts team_* tools:
team_send_messageteam_broadcastteam_list_tasksteam_claim_taskteam_release_taskteam_reassign_taskteam_heartbeat_task
Other tools are delegated straight to the original executor. If there is no delegate and a member invokes a non-team tool, it throws immediately.
This again proves:
- Team tools are not a separate runtime magic
- They are a wrapper layer on the unified tool execution chain
8. What parallelToolCalls actually means
BaseAgentRuntime.runInternal() executes tools in parallel when two conditions are met:
context.getParallelToolCalls() == true- The current turn has more than one valid tool call
This imposes a hard requirement on the executor:
- Your
ToolExecutormust be thread-safe
If the executor reuses mutable state internally, shares temp files, or relies on single-threaded ordering, turning on parallelism will break it. This problem usually does not surface as a model-layer error; it shows up as a tool-layer race condition.
9. Typical wiring patterns
9.1 Quickly assemble a unified tool surface with ToolUtil + MCP
Agent agent = Agents.react()
.modelClient(modelClient)
.model("gpt-4.1")
.toolRegistry(
java.util.Arrays.asList("queryWeather", "read_file"),
java.util.Arrays.asList("github", "filesystem")
)
.build();
Suitable for:
- Tools are already registered in
ToolUtilor MCP services - You only want to expose a minimal allowlist to the current Agent
9.2 Custom executor for approval and audit
ToolExecutor guardedExecutor = call -> {
approvalService.requireApproved(call.getName(), call.getArguments());
auditService.record(call);
return ToolUtil.invoke(call.getName(), call.getArguments());
};
Agent agent = Agents.builder()
.modelClient(modelClient)
.toolRegistry(java.util.Arrays.asList("queryWeather"), null)
.toolExecutor(guardedExecutor)
.build();
Suitable for:
- You do not want permission approval scattered across individual tool functions
- You want rejection, audit, and failure information to be folded into the loop uniformly
9.3 Schema and execution fully separated
AgentToolRegistry registry = new StaticToolRegistry(myToolSchemas);
ToolExecutor executor = call -> gateway.execute(call);
Agent agent = Agents.builder()
.modelClient(modelClient)
.toolRegistry(registry)
.toolExecutor(executor)
.build();
Suitable for:
- The schema source is not
ToolUtil - Real execution has to go through a remote gateway, sandbox, or proxy process
10. Failure boundaries and easy misreadings
10.1 Tool results default to string semantics
The return value of ToolExecutor.execute(...) is String. Complex objects ultimately have to be serialized to a string by you before the model consumes them.
10.2 The default executor is only friendly to the default tool system
If you use a custom registry but do not explicitly provide an executor, the Builder most likely cannot help you. The default executor's creation logic assumes it can understand the tool objects in the registry.
10.3 The tool allowlist only constrains the default executor
ToolUtilExecutor validates allowedToolNames, but only for itself. Once you switch to a custom executor, allowlist, deny-list, and approval are all on you.
10.4 Tool failure does not terminate the Agent by default
This is usually correct, because the model can still decide to retry, change arguments, or switch strategy based on TOOL_ERROR. But if your business requires "certain tools failing must abort immediately", you have to implement that explicitly in the executor, instead of assuming the runtime will do it for you. The default error payload has only errorType, error, tool, and callId; no stack trace.
11. When debugging, look at these entry points first
When you hit "the model can see tools but cannot invoke them", "approval logic is not working", or "the tool returned but the next turn did not use it", look first at:
- What
toolRegistryandtoolExecutorAgentBuilder.build()ultimately produced - Whether
AgentToolCallSanitizer.validationError(...)blocked the call as a structural error - Whether
ToolExecutor.execute(...)actually reached the target logic - Whether
BaseAgentRuntime.executeTool(...)wrapped the exception into aTOOL_ERROR - Whether
memory.addToolOutput(...)got a stablecallId
These spots get you closer to the root cause than staring at the final natural-language output.