Friday, August 21, 2026

Next.js Dynamic Routes now also available in Faces!

OmniFaces 5.5 has been released! It's a relatively small release but with one big addition in FacesViews.

New: dynamic route segments

How do you serve a URL like /organizations/123/members in Faces without complex URL-rewriting rules? Until now the only answer was PHP-inspired MultiViews configured as follows in web.xml:

<context-param>
    <param-name>org.omnifaces.FACES_VIEWS_SCAN_PATHS</param-name>
    <param-value>/*.xhtml/*</param-value>
</context-param>

<welcome-file-list>
    <welcome-file>index</welcome-file>
</welcome-file-list>

And this in the bean associated with /organizations/index.xhtml:

@Inject
@Param(pathIndex = 0)
private Long id; // So we can preload in @PostConstruct.

@Inject
@Param(pathIndex = 1)
private String action; // Can be e.g. a path for ui:include (use enum/validator for robustness).

Whereafter the view has to branch on that action to decide whether it shows the members, the settings, or something else. E.g. a dynamic include path <ui:include src="/WEB-INF/includes/organization/#{bean.action}.xhtml" /> or even conditional rendering. Every subpage you add is another branch, and a typo in the URL possibly arrives as a perfectly valid action which nothing renders. In other words, the URL structure ends up living in your bean instead of in your file structure.

As of OmniFaces 5.5 you can put the variable part in a directory name instead, wrapped in square brackets, the way you may already know it from Next.js and friends in case you're also familiar with them. Given the following file structure:

src
└── main
    └── webapp
        └── organizations
            ├── [id]
            │   ├── index.xhtml
            │   └── members.xhtml
            └── settings
                └── members.xhtml

Yes, the directory on disk is literally named [id]. Square brackets and all, and that's exactly what marks it as a dynamic route segment. The above file structure makes the Facelets available via the following URLs:

example.com/organizations/123                (forwards to /organizations/[id]/index.xhtml with segment "id" being "123")
example.com/organizations/123/members        (forwards to /organizations/[id]/members.xhtml with segment "id" being "123")
example.com/organizations/settings/members   (forwards to /organizations/settings/members.xhtml without any segment)

This needs no additional configuration beyond the earlier shown web.xml. The segment value is injectable by name via the new pathName attribute of @Param which must match exactly the directory name without square brackets:

@Inject
@Param(pathName = "id")
private Long id;

A link to such a view spells out the route in the outcome and supplies each segment with the also new name attribute of <o:pathParam>:

<h:link value="Members" outcome="/organizations/[id]/members">
    <o:pathParam name="id" value="#{organization.id}" />
</h:link>

which renders as <a href="/context-path/organizations/123/members">Members</a>. Without a name the <o:pathParam> tag keeps behaving as before and appends the value as the next positional path parameter of a MultiViews view.

Of course, just inlining continues to work totally fine:

<h:link value="Members" outcome="/organizations/#{organization.id}/members" />

Rules of the road

A request path is resolved by first looking for an exact match among the scanned views, then walking the dynamic route segments, and only then falling back to MultiViews. A literal directory always wins from a dynamic one at the same level, which means a literal sibling is a value the segment can never take; in the above example an organization whose id is settings is thus unreachable. An application without any bracketed directory never reaches the dynamic route resolution at all and therefore behaves exactly as before.

The segments nest and combine flawlessly with MultiViews, so a view of /[locale]/products/[sku]/reviews.xhtml answers to /nl/products/12345/reviews/2 with nl and 12345 available as @Param(pathName = "locale") and @Param(pathName = "sku"), and 2 available as @Param(pathIndex = 0) in the bean associated with reviews.xhtml.

Only a directory name is interpreted this way. A bracketed file name such as /organizations/[oid]/members/[mid].xhtml with the intent to capture /organizations/123/members/456 is ignored and logged as a warning, especially because some containers reject those files with a 400 during RequestDispatcher.forward before the request even reaches the FacesServlet. The correct solution therefor is to continue using the MultiViews @Param(pathIndex = 0) in the bean associated with /organizations/[id]/members.xhtml. The more future-proof way, though, is to make use of the welcome file facility /organizations/[oid]/members/[mid]/index.xhtml and a @Param(pathName = "mid") in the bean associated with index.xhtml. This way we can easily extend to e.g. /organizations/[oid]/members/[mid]/settings.xhtml.

All in all, your URL structure is now simply your file structure again, also when it has variable parts in it. See also the FacesViews documentation for the complete story.

Fixes

A handful small fixes next to the one big ticket feature:

<o:massAttribute> failed to apply the attribute to components conditionally created by any nested JSTL tags or dynamic includes when the view is built for the second time under different conditions during render response of a postback (#990). This gap was discovered while reviewing and improving the view build time of Mojarra.

@ViewScoped unload confirmation can now finally also be registered via the canonical and modern JS way window.addEventListener("beforeunload", handler) instead of only with window.onbeforeunload = handler (#986).

@ViewScoped answers the unload beacon with a 204 instead of a bodyless 200, so that an in-flight navigation (e.g. redirect from view scoped bean action) cannot anymore potentially collide with the unload when you have a window.beforeunload handler registered, which would listen on pagehide event instead (#989).

PWAResourceHandler service worker cache is now not anymore stale when resource contents change rather than resource set itself (#987).

You can find the complete list of additions, changes and fixes at What's new in OmniFaces 5.5? in the showcase.

Installation

Non-Maven users: download OmniFaces 5.5.2 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>omnifaces</artifactId>
    <version>5.5.2</version>
</dependency>

How about OmniFaces 4.x and 3.x?

OmniFaces 4.7.13 and 3.14.24 have been released as well. They contain the <o:massAttribute>, @ViewScoped unload 204 and PWAResourceHandler fixes, but not the dynamic route segments, nor the @ViewScoped addEventListener which are 5.x only.

Thursday, August 20, 2026

OmniHai gets motion

OmniHai 1.7 has been released! In 1.1 OmniHai grew ears and in 1.6 it got hands. In 1.7 it gets motion, in both directions: hand it a recording and ask what happens in it, or describe a scene and get one back. Relatively a lot of smaller things have been added around that, such as classification against your own labels, audio generation through OpenRouter, and a modality check which looks up what a model can do instead of guessing it from the name.

New: analyzeVideo()

How do you ask an AI when the goal happens in a match recording? Until now you had to cut the video into frames yourself and send those as images, which loses the timing and makes you pay per frame. OmniHai 1.7 sends the video itself:

String answer = service.analyzeVideo(Path.of("match.mp4"), "When does the goal happen?");

The video may also be passed as a byte[], and there is an async variant analyzeVideoAsync(), as with every other operation.

An AI does not watch a video the way you do. It samples frames from it, by default one per second, and reads those. On a long recording that is a lot of frames for a question about one minute of it. You can therefore state which part to sample and how densely, with AnalyzeVideoOptions:

String answer = service.analyzeVideo(Path.of("match.mp4"), "When does the goal happen?",
    AnalyzeVideoOptions.newBuilder()
        .fps(2)
        .startOffset(Duration.ofMinutes(1))
        .endOffset(Duration.ofMinutes(2))
        .build());

Video input is currently only accepted by Gemini (Google AI), by Muse Spark (Meta AI; note that Meta AI is now finally available for public whereas it was previously preview only) and by the video capable models which OpenRouter routes. The sampling options of AnalyzeVideoOptions are honored by Gemini only.

The other providers do not accept a video at all: OpenAI, Azure, Anthropic, xAI, Mistral and Ollama. This is not OmniHai holding something back. Their APIs simply do not support video analysis, so supportsModality() continues to answer false for VIDEO_ANALYSIS on those providers, so you can pick a service which can do it before you call.

Gemini does not take a video in the request itself. It must first be uploaded, and the AI provider then needs a moment to process it before it can be referred to, which on a larger file is longer than one round trip. OmniHai waits for that state to become active and only then asks the question, so you do not have to poll it yourself. A video which the provider rejects fails with an AIException stating why. OpenRouter takes the video as a plain data URI only and has nowhere to put them, so be careful with large videos.

New: generateVideo()

Making a video does not fit in one call. The AI provider answers within seconds with a job id and then takes minutes to actually produce the video, and it never calls you back. A method which just returns the video would sit and wait for those minutes, which you cannot do in a request. So generateVideo() hands you a handle on the job as soon as it is accepted:

VideoGeneration video = service.generateVideo("Sunrise over the colorful houses of Willemstad");
String jobId = video.jobId();

The handle is serializable and carries that job id, so you can submit in one request and look at it again in a later one, after a restart or on another node. Keep the id, and pick the job up again with findVideoGeneration():

VideoGeneration video = service.findVideoGeneration(jobId);

if (video.refresh().status() == VideoGeneration.Status.COMPLETED) {
    video.writeTo(Path.of("curacao.mp4"));
}

status() is a plain getter which does no I/O at all, so it costs you nothing to call it from a render pass. refresh() is the one which asks the AI provider, exactly one request per call, on your own schedule.

When you do want to wait, generateVideoAsync() does the polling for you and completes when the video is there:

CompletableFuture<VideoGeneration> pending = service.generateVideoAsync("Sunrise over the colorful houses of Willemstad");

pending.thenAccept(video -> video.writeTo(Path.of("curacao.mp4")));

Asking for a path writes the video there for you, asynchronously or not:

CompletableFuture<Void> written = service.generateVideoAsync("Sunrise over the colorful houses of Willemstad", Path.of("curacao.mp4"));

service.generateVideo("Sunrise over the colorful houses of Willemstad", Path.of("curacao.mp4"));

That last one blocks for as long as it takes, so it is for a batch job rather than for a request. The polling stops as soon as you stop watching, so a handle nobody looks at costs nothing. A job which never finishes fails after five minutes rather than keeping you there, which you can raise with GenerateVideoOptions, together with the aspect ratio, the resolution and the duration.

Video generation is offered by Google with Veo, by xAI with Grok Imagine, and by OpenRouter, which routes the generators of several labs. OpenAI and Azure both retire Sora without a successor to route to, so supportsModality() continues to answer false for VIDEO_GENERATION there, as it does on Anthropic, Mistral, Meta, Hugging Face and Ollama.

The generated video is hosted by the AI provider for about a day and is then deleted, upon which the status becomes EXPIRED. Some of them host it on a separate host and hand out a pre-signed URL for it; OmniHai downloads such a URL without your API key, which has no business leaving the endpoint it belongs to.

New: classify() and classifyAll()

Which queue does this support ticket belong in? You could ask that in a chat prompt, but then you have to parse whatever came back, and "This looks like a billing issue." is not a queue name. AIService now has a method for it:

ClassificationResult result = service.classify(ticket, "billing", "shipping", "technical");
route(result.label());

The labels you offer are the only values the AI may answer with. This uses structured outputs under the covers: a schema, which is a description of the exact shape the answer must have, is sent along with the question, and the AI provider holds the model to it. So label() is never anything else than one of your labels, and you can switch on it without a default branch for surprises. You do not write that schema yourself here; classify() derives it from the labels you pass in.

The AI must pick one even when nothing fits well, which is what confidence() is for. It runs from 0.0 to 1.0 and tells a certainty apart from a guess:

if (result.confidence() < 0.7) {
    queueForReview(ticket);
}

A ticket can of course be about two things at once. classifyAll() scores every label on its own merit and returns them with the best fitting one first:

List<ClassificationResult> results = service.classifyAll(ticket, "billing", "shipping", "technical");
List<String> tags = results.stream().filter(r -> r.confidence() > 0.5).map(ClassificationResult::label).toList();

The scores are not divided among the labels here, so several of them may score high, or none of them. A text which belongs to none of your labels scores low on all of them, which is the answer you want and which classify() cannot give you.

New: audio generation through OpenRouter

generateAudio() has been in OmniHai since 1.2, on OpenAI and Google. OpenRouter now joins them, by a different route. It does have an audio/speech endpoint, but that one serves a model catalog of its own which the model listing does not enumerate, so there is no telling which model to ask. Audio comes out of an audio capable chat model instead, which streams it back in chunks. OmniHai collects those and assembles a playable WAV file from it, so the call is the same one you already know:

service.generateAudio("Hello world", Path.of("hello.wav"));

The audio is written straight to the given path while it streams in, so a long text does not have to fit in memory first. Hugging Face still cannot do this; the text-to-speech models there are served by third parties with their own API rather than via Hugging Face's own API.

Modality lookup instead of name matching

A modality is a kind of input or output: text, image, audio, video. supportsModality() tells you whether a service can handle one, so you can check up front rather than by catching:

if (service.supportsModality(AIModality.VIDEO_ANALYSIS)) {
    return service.analyzeVideo(video, "Summarize this");
}

Most providers publish one model family per endpoint, so the answer follows from the predefined model names and versions. Aggregators do not; OpenRouter and Hugging Face route hundreds of models from many labs, and a name says nothing about them. Of the OpenRouter models which accept video, not one carries "video" in its name. Both of them do publish the input and output modalities per model, which OmniHai now looks up instead of guessing or even hardcoding.

That listing is fetched at most once a day per endpoint and shared by every service instance on it, so the first call blocks on one HTTP request and the rest are answered from memory. When it cannot be obtained, matching the model name is the fallback, and the last known listing is kept rather than cleared.

It stays a hint, not a guarantee. A published listing goes stale, a name matched guess is a guess, and a provider may still refuse a call it advertises, for this key, this region or this moment. The only guarantee is to make the call and handle its failure. The actual calls do namely not precheck the supportsModality().

Smaller things

analyzeImage() and generateAltText() now also accept a Path, as the video and audio methods already did.

ModerationOptions now also accepts a List of categories instead of only String... varargs.

Installation

Non-Maven users: download OmniHai 1.7 JAR and drop it in /WEB-INF/lib the usual way, replacing the older version if any.

Maven users: use the following coordinates.

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnihai</artifactId>
    <version>1.7</version>
</dependency>

Tuesday, August 11, 2026

OmniHai gets hands

OmniHai 1.6 is out!

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.

Minimum requirements are unchanged: Java 17 and Jakarta EE 11 or MicroProfile 7. JSON-P is the only required dependency, and CDI, EL and MP Config remain optional.