Skip to main content

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:

  • AgentToolRegistry owns the exposure surface
  • ToolExecutor owns the execution surface
  • BaseAgentRuntime owns 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.java
  • ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/ToolExecutor.java
  • ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentBuilder.java
  • ai4j-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:

  • StaticToolRegistry
  • CompositeToolRegistry
  • ToolUtilRegistry
  • AgentTeamToolRegistry

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();
Tenant filter must be fixed on the server side

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 CompositeToolRegistry to merge the original tool surface with the subagent tool surface
  • Use SubAgentToolExecutor to 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:

  • outputText
  • memoryItems
  • toolCalls

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
  • arguments must be a JSON object
  • The required fields for the different bash actions exist
  • read_file.path is not empty
  • apply_patch.patch is 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 planApproval and hooks
  • 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.:

  • allowedTools
  • deniedTools
  • maxDepth
  • timeoutMillis
  • inputFilter
  • onDenied
  • onError

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_message
  • team_broadcast
  • team_list_tasks
  • team_claim_task
  • team_release_task
  • team_reassign_task
  • team_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 ToolExecutor must 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.

8.1 Concurrency cap and per-call timeout​

Parallel dispatch is governed by two optional knobs (Blueprint $.model.options accepts the same names, plus the snake_case aliases max_parallel_tool_calls / tool_call_timeout_millis):

Agent agent = Agents.react()
.modelClient(modelClient)
.model("gpt-4.1")
.parallelToolCalls(true)
.maxParallelToolCalls(4) // at most 4 concurrent tool executions per turn
.toolCallTimeoutMillis(30_000L) // a single tool exceeding 30s is recorded as FAILED
.build();
  • maxParallelToolCalls: the per-turn dispatch pool shrinks to min(batch size, cap); null or <= 0 keeps the historical behavior (one thread per call).
  • toolCallTimeoutMillis: a timed-out call is interrupted via cancel(true) and recorded as a FAILED result (ok=false, output is a TOOL_ERROR JSON) without affecting its siblings — the same convention as a tool that throws. null or <= 0 disables the timeout.
  • Results are still written back to memory in the model's call order; the session event contract ordering is unchanged.
  • Dispatch pool workers are daemon threads (ai4j-tool-exec-N), so a leaked worker cannot block JVM shutdown.

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 ToolUtil or 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 toolRegistry and toolExecutor AgentBuilder.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 a TOOL_ERROR
  • Whether memory.addToolOutput(...) got a stable callId

These spots get you closer to the root cause than staring at the final natural-language output.

12. Further reading​

  1. Memory and State
  2. Minimal ReAct Agent
  3. Subagent Handoff Policy
  4. Agent Teams
  5. Trace Observability