Version 1.0 gave it structure, version 1.1 ears, 1.2 a voice, 1.3 let it browse, 1.4 taught it to count the cost, and 1.5 gave it a backbone. Version 1.6 gives it hands! The AI can now act as an agent and reach out to call your own methods before it answers, and it works on every supported provider.
What an agent is
Half the industry is currently selling you "agents". Strip the marketing and an agent is an AI with access to tools. That is all it is.
A "tool" is basically one of your own methods, plus a sentence in natural language which clearly says what it does. That sentence is all the AI gets. It never sees the method body, and it cannot run anything at all, because a model produces text and nothing else. A tool call is therefore just the AI writing down a name and some arguments, and your own code deciding what to do with that.
This makes the tool description the actual API. The AI picks a tool by reading the description, not by matching a type signature, so a vague description is a bug like any other. "Looks up a single order by id" gets picked when someone asks about order 42. "Handle orders" does not. So describe carefully in natural language.
Add a list of tools to the system prompt and ask a question. The AI either answers or it asks for a tool to be called. When it asks for a tool to be called, you call it and you hand back what it returned in natural language, and you repeat that until it answers. The AI never runs anything itself. It only asks, and your own code decides.
So there is no autonomy in it, and no intelligence beyond the model you already had. What you add is a list of methods and a loop. Once your AI can call your methods, you have an agent, and you can stop wondering whether you need a whole framework for it.
Tools
Annotate a method with @AITool, describe its parameters with @AIToolParam, and hand the object over. It's nicer if you have a dedicated CDI bean for this:
@ApplicationScoped
public class OrderTools {
@Inject
private OrderService orders;
@ReadOnly
@AITool("Looks up a single order by id")
public String findOrder(@AIToolParam("The order id") long orderId) {
return orders.findById(orderId).map(Order::toSummary).orElse("No order found with that id.");
}
@ReadOnly
@AITool("Lists the orders placed by a customer")
public String listOrders(@AIToolParam("The customer email") String email) {
var summaries = orders.listByEmail(email).stream().map(Order::toSummary).collect(joining("\n"));
return summaries.isEmpty() ? "No orders found for that email." : summaries;
}
@AITool("Refunds an order and pays the money back to the customer")
public Refund refundOrder(@AIToolParam("The order id") long orderId) {
return orders.refund(orderId);
}
}
@Inject
@AI(apiKey = "#{keys.openai}", tools = OrderTools.class)
private AIService agent;
public String handle(String question) {
return agent.chat(question);
}
Each turn the AI either names a tool or answers. A named tool is invoked with its arguments converted to the declared parameter types, the return value is fed back, and the next turn begins. A tool may return anything; the AI is handed its toString(), so you need to ensure that your tool returns something which reads as an answer in natural language or even JSON-y. A String or Record, or a properly implemented toString() does nicely, a bare com.example.Bean@hashcode does not.
There is no classpath scanning. Only the classes you explicitly pass to tools are scanned for methods annotated @AITool (and cached for performance). Exposing your own code to model output must to be a deliberate act.
@ReadOnly is explained later in "Grouping tools".
How it works
OmniHai does not use the provider's native function calling to register tools. It uses the same provider-enforced structured outputs which chat(message, DesiredOutput.class) already supported since 1.0. The response schema constrains the AI to either name one of your tools or answer, and the tool name is an enumeration of exactly the tools you registered.
Two things travel along with every request. The first one is the manifest: a plain text list of your tools, appended to the system prompt. The system prompt is the block of instructions which is sent ahead of your question on every call. For the OrderTools above it looks exactly like this:
Available tools: - OrderTools_findOrder(orderId: The order id) -> Looks up a single order by id - OrderTools_listOrders(email: The customer email) -> Lists the orders placed by a customer - OrderTools_refundOrder(orderId: The order id) -> Refunds an order and pays the money back to the customer Call exactly one tool per turn, or answer directly once you have enough information. Never invent data a tool did not return. There is nobody to ask for more information, so when a tool needs a value you do not have, obtain it from another tool first.
This is the entire documentation the AI gets. The tool name is derived from the class and the method, so findOrder() of OrderTools becomes OrderTools_findOrder, which keeps two classes with a findOrder() of their own apart. Every parameter shows up as its name plus your @AIToolParam description. Your @AITool description follows after the arrow. Nothing else of your class is exposed; not the return type, not the other methods, and certainly not the bodies.
The second one is the structured output. This is a JSON schema which the provider enforces on its own output, generated from the registry:
{
"type": "object",
"properties": {
"tool": {
"type": "string",
"enum": ["OrderTools_findOrder", "OrderTools_listOrders", "OrderTools_refundOrder", "ANSWER"]
},
"arguments": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"value": { "type": "string" }
},
"required": ["name", "value"]
}
},
"answer": { "type": "string" }
},
"required": ["tool", "arguments", "answer"]
}
The enum is the interesting part. It holds your three tools plus ANSWER, and nothing else. The AI cannot call a tool which is not in the registry, simply because there is no way for it to say so. That is a different thing than asking it nicely in the prompt: the name is not representable at all.
The arguments arrive as name/value pairs instead of as a free-form object. That is on purpose. The strict schema modes of most providers require every property of an object to be declared up front, and those properties differ per tool. A list of pairs is one single shape which fits every tool. OmniHai converts each value to the declared Java type of the parameter, so orderId arrives in your method as a long.
A turn is one request plus one response. Ask "Where is order 42?" and the first turn comes back like this:
{
"tool": "OrderTools_findOrder",
"arguments": [{ "name": "orderId", "value": "42" }],
"answer": ""
}
OmniHai invokes the method and feeds the returned value back as the message of the next turn:
You called OrderTools_findOrder(orderId=42) and it returned: Order 42, in transit, shipped on 2026-08-03, tracking NL1234567890 Do not call it again with the same arguments. Answer the question now if this is enough, otherwise call another tool.
The second turn then comes back with ANSWER as tool and the prose in answer, and the loop ends.
{
"tool": "ANSWER",
"arguments": [],
"answer": "Order 42 is still in transit. It shipped on 2026-08-03 and you can follow it with tracking code NL1234567890."
}
Two turns, two provider calls, one method invocation on your own thread.
This has one big advantage: there is no per-provider wire format to maintain. Tools behave identically on OpenAI, Anthropic, Google, xAI, Mistral, Meta, Azure, OpenRouter, Hugging Face and Ollama. The provider's own function calling API is never touched, so a provider which adds one tomorrow needs no work here.
Declaring tools programmatically
Outside CDI, or when the tools depend on the request rather than on the injection point, you can compose everything yourself and pass a concrete instance of the tools:
AIService ai = AIConfig.of(AIProvider.OPENAI, System.getenv("OPENAI_API_KEY")).createService();
AIService agent = ai.withTools(orderTools);
String answer = agent.chat(question);
Not every tool wants an annotation either. ToolRegistry is the programmatic counterpart, in the same way AIConfig is the programmatic counterpart of the @AI qualifier. A method reference cannot carry a name, a description or its parameter names, because those are erased, so you state them:
ToolRegistry tools = ToolRegistry.newBuilder()
.add("FIND_ORDER_BY_ID", "Looks up a single order by id", orders::findById, ToolParam.of(long.class, "orderId", "The order id"))
.add("LIST_OPEN_ORDERS", "Lists all open orders", orders::listOpen)
.add(shippingTools)
.build();
AIService agent = ai.withTools(tools);
A tool taking no arguments needs nothing beyond its description, so a plain method reference is enough. Annotated objects and lambdas mix freely in one registry.
Grouping tools
The object you hand over is already a group, and AIService#withTools() accepts varargs, so a class is a toolset. For subsets which cut across classes, such as read-only versus mutating, you declare your own tag with the @AIToolGroup meta-annotation:
@AIToolGroup
@Retention(RUNTIME)
@Target(METHOD)
public @interface ReadOnly {}
@Inject
@AI(apiKey = "#{keys.openai}", tools = OrderTools.class, toolGroup = ReadOnly.class)
private AIService tier1;
@Inject
@AI(apiKey = "#{keys.openai}", tools = { OrderTools.class, ShippingTools.class })
private AIService supervisor;
Programmatically that is ai.withTools(ReadOnly.class, orderTools) respectively ai.withTools(orderTools, shippingTools).
Narrowing applies to the generated schema, not to a check afterwards. The tier-1 agent cannot name refundOrder at all, because that token is not in the grammar the model decodes against. That is a stronger guarantee than a permission check which runs after the AI already asked.
Who is asking
Grouping decides which tools exist. It does not decide which rows they may hand back. The AI picks the arguments, and it picks them from everything it has read: your question, and every tool result before it. So orderId is not a value you chose, it is a value which arrived from outside, exactly like a request parameter. A lookup by id or by email which does not check who is asking lets any customer read any customer's orders, and the OrderTools above only reads well because OrderService is assumed to do that check.
Keep the check there and not in the tool. The tool is an adapter: it turns a name/value pair into a method call and a result into a sentence. The rule about who may see an order belongs to the thing which owns orders, or you write it again in every backing bean and every REST resource, and the tool is the one which gets forgotten.
@ApplicationScoped
public class OrderService {
@Inject
private SecurityContext security;
@Inject
private OrderRepository repository;
public Optional<Order> findById(long orderId) {
return repository.findById(orderId).filter(order -> mayRead(order.getCustomer().getEmail()));
}
public List<Order> listByEmail(String email) {
return mayRead(email) ? repository.listByEmail(email) : emptyList();
}
@Transactional
public Refund refund(long orderId) {
if (!security.isCallerInRole("SUPPORT")) {
throw new SecurityException("Refunding requires the SUPPORT role.");
}
return repository.refund(orderId);
}
private boolean mayRead(String email) {
var caller = security.getCallerPrincipal();
return security.isCallerInRole("SUPPORT") || (caller != null && (caller.getName().equalsIgnoreCase(email));
}
}
Return nothing rather than throw. The tool then answers "No order found with that id.", which is true for this caller and does not confirm that the order exists for somebody else. The security context is in scope because the synchronous chat invokes the tool on your own thread, which is covered further below.
Dropping a parameter the caller has no business choosing is stronger still. A listMyOrders() which takes the email from the principal leaves nothing in the schema to aim at, in the same way that a tool outside the group leaves nothing in the enumeration to name. The email-taking variant then belongs in a support-only tool group.
Scopes and composition
@Inject
@AI(apiKey = "#{keys.openai}", tools = OrderTools.class, maxToolCalls = 4, maxAttempts = 3)
private AIService agent;
The tool classes named on the qualifier are resolved as CDI beans, which must be normal-scoped so that they observe their own scope and their own interceptors on every call. A @Dependent one is rejected at injection time instead of being silently pinned to the lifecycle of the injection point. Handing withTools an object yourself as in AIService agent = ai.withTools(orderTools) resolves nothing; that object is used as it is, and whether it is a bean at all is your own business.
The maxAttempts attribute is new too. It composes the in 1.5 introduced RetryingAIService around the produced service, and tool calling is composed around that, so a retry re-attempts a single provider call rather than replaying the whole loop and every side effect it already caused.
Bounding the loop
The tool call cap bounds latency and spend. It defaults to five, after which the AI must answer, so a conversation takes at most six provider calls.
The turn after the last permitted call is offered no tool at all. Its schema enumerates only the answer, so a model which keeps reaching for tools is denied the tokens to name one. Asking it politely to stop is not enough, as a weaker model simply ignores prose. AIToolIterationException is what is left as the backstop for a provider which does not enforce the schema. Asking for a type forces the typed answer at the cap instead of throwing, as that call carries your own schema and offers no tool to begin with.
An observer receives every tool call after it ran, which is your audit point for logging and metrics:
AIService agent = new ToolCallingAIService(ai, ToolRegistry.of(orderTools), 4, invocation -> {
if (invocation.hasFailed()) {
logger.log(WARNING, () -> "Tool " + invocation.toolName() + " failed");
}
});
A tool which throws, or whose arguments cannot be converted, is reported back to the AI rather than aborting the call, so it can correct itself and try again. This self-correction is the main thing the loop buys you over a single call. What the AI is told is deliberately bounded: an argument it got wrong is quoted back verbatim, while an exception thrown by the tool itself is reduced to a generic line and logged. A stack trace, a SQL error or a constraint message must never reach the AI and be repeated to a user. Your observer still receives the real exception. An Error is not reported back at all but rethrown, because it is not something the AI can work around.
The synchronous chat methods invoke the tools on the calling thread, so they observe its transaction, scope and security context. The asynchronous ones invoke them on whichever thread completes the provider call, where none of that applies. Use the synchronous ones for tools which touch a database or a scoped bean.
Limits
Tool use and a typed result each want the one response schema a provider call carries, so they take a turn each. The loop runs on its own schema, and the turn which would have answered is asked again for your type instead, with every tool result still in front of it:
public record Delivery(String carrier, LocalDate estimated) {}
Delivery delivery = agent.chat("Where is order 42?", Delivery.class);
That costs one extra call, and only when you ask for a type: five tool calls take six provider calls, and seven when the conversation has memory, as the answer it records is then a call of its own. Streaming cannot be combined with tools at all, because the tool which the AI picks is only known once its reply is complete, so chatStream on a tool calling service throws UnsupportedOperationException rather than quietly answering without the tools.
Further, the AI calls one tool per turn rather than several at once, there is no way to force a specific tool, and arguments are converted from strings rather than typed per tool on the wire. Those three need the provider's native function calling, which stays out of scope for now.
Installation
Non-Maven users: download OmniHai 1.6.1 JAR and drop it in /WEB-INF/lib the usual way, replacing the older version if any.
Maven users:
<dependency>
<groupId>org.omnifaces</groupId>
<artifactId>omnihai</artifactId>
<version>1.6.1</version>
</dependency>
@AITool was introduced in 1.6 and it works fine, but 1.6.1 has further improved the tool naming so that it has better support for duplicate/overloaded methods.

No comments:
Post a Comment