LangChain4j: one tool provider for per-turn tool selection
Register your tools with ToolcueToolProvider instead of AiServices.tools(...). Each user message gets only the tools it needs, chosen once by Toolcue; every step of the tool loop keeps that list, and a find_tools tool lets the model recover one that was left out.
Verified on 2026-11-04 with LangChain4j 1.13.0 and 1.23.0, with 9 end-to-end tests and a real Gemini run. Java 17+. Toolcue is in early access: join the waitlist.
Install#
<dependency>
<groupId>dev.toolcue</groupId>
<artifactId>toolcue</artifactId>
<version>0.1.0</version>
</dependency>The toolcue artifact has no dependencies; it uses your application's LangChain4j (1.13 or later). Set TOOLCUE_URL, TOOLCUE_TOKEN and TOOLCUE_ADMIN_TOKEN, or pass them to ToolcueClient.builder().
Use it#
ToolcueToolProvider tools = ToolcueToolProvider.builder(ToolcueClient.create(), "acme-billing")
.tools(new BillingTools()) // objects with @Tool methods
.user(request -> Map.of("roles", request.invocationParameters().getOrDefault("roles", List.of())))
.build();
// at deploy time: publish the catalog, with what the annotations can't say
tools.publish(Map.of(
"find_customer", t -> t.withProduces("customer_id"),
"update_tax_settings", t -> t.withRequiredRoles("admin"),
"ask_user", t -> t.withPinned(true)));
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(model)
.chatMemoryProvider(memory)
.toolProvider(tools)
.build();Pass the user per call with an InvocationParameters argument on your AI service method (or look them up from the chat memory id in user(...)).
How the provider works#
| When | What it does | |
|---|---|---|
| First call | Before the turn's first model call | Sends the user message, the earlier messages (with tool calls and results) and the user to Toolcue |
| Later calls | Before every model call of the tool loop | Returns the same tools, plus any find_tools found this turn |
find_tools | When the model calls it | Searches the whole catalog with the user's roles; the tools found are added for the rest of the turn |
| Tool calls | Every tool call | Only offered tools have executors, so a hidden tool can't run |
The provider is dynamic (isDynamic()), which is why it needs LangChain4j 1.13 or later.
Pins and scopes#
ToolcueToolProvider.builder(client, "acme-billing")
.tools(new BillingTools())
.pinned(request -> PAGE_TOOLS.getOrDefault(request.invocationParameters().getOrDefault("page", ""), List.of()))
.only(List.of("find_customer", "refund_invoice")) // a refunds-only assistant
.build();pinned adds tools on every turn, with their lookups; only makes Toolcue choose only among some tools. Both take a list or a function of the request. Role rules always win.
When Toolcue is down#
The provider never fails a turn. If Toolcue is slow (over 0.5 s), unreachable or failing, the turn gets your fallbackTools(...), else only plus the pinned tools, else every tool, and a warning is logged. After three failures in a row it stops calling for 30 seconds.
Compared with LangChain4j's own tool filtering#
LangChain4j can filter tools with a model call before each request, or let the model search for deferred tools. Toolcue needs no model call (tens of milliseconds), reads the whole conversation, adds the lookups the chosen tools depend on and applies the user's roles. Keep find_tools for the rare miss.
Run the example#
LangChain4jAgent.java in the Java examples runs four turns with Claude, Gemini or any OpenAI-compatible model and prints the tools each call got.