This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new ae7eeb4f0fa7 CAMEL-23928: Add tool-calling behavior tests and 
documentation for AgentConfiguration
ae7eeb4f0fa7 is described below

commit ae7eeb4f0fa78e79a6fc3a5869a71b5d11b466b4
Author: Omar Atie <[email protected]>
AuthorDate: Wed Sep 2 00:27:23 2026 -0700

    CAMEL-23928: Add tool-calling behavior tests and documentation for 
AgentConfiguration
    
    The core CAMEL-23928 API (first-class tool-calling options on 
AgentConfiguration
    and the withAiServicesCustomizer() escape hatch) was already on main via 
#24492.
    This adds behavioral test coverage and component documentation so the 
feature is
    discoverable and verified end-to-end: unit tests for hallucinated tool name
    recovery, max tool round-trip enforcement, tool execution error handling and
    compensation, and customizer-driven beforeToolExecution wiring; integration
    tests through the langchain4j-agent component; and documentation covering
    first-class tool-calling methods and the AiServices customizer escape hatch.
    
    Closes #26027
    
    Co-authored-by: Cursor Agent <[email protected]>
---
 .../catalog/docs/langchain4j-agent-component.adoc  |  42 +++++
 .../AgentConfigurationToolCallingBehaviorTest.java | 208 +++++++++++++++++++++
 .../src/main/docs/langchain4j-agent-component.adoc |  42 +++++
 .../LangChain4jAgentAiServicesCustomizerTest.java  | 149 +++++++++++++++
 4 files changed, 441 insertions(+)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc
index e70651121988..5d2fd309ffa9 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc
@@ -159,6 +159,48 @@ Agents are configured using the `AgentConfiguration` class 
which provides a flue
 * Retrieval Augmentor (for RAG functionality)
 * Input and Output Guardrails
 * Concurrent tool execution (`withExecuteToolsConcurrently`) for parallel 
Camel route tools and MCP tools within one LLM round trip
+* Tool-calling control: round-trip limits, hallucinated tool handling, and 
error compensation
+* AiServices builder customizer for advanced LangChain4j options
+
+==== Tool-calling options
+
+`AgentConfiguration` exposes the most common LangChain4j `AiServices` 
tool-calling settings as first-class fluent methods. These are applied 
automatically when Camel creates an agent from the configuration bean (inline 
agent mode or a registered `AgentWithMemory` / `AgentWithoutMemory` bean):
+
+[cols="1,2"]
+|===
+| Method | Purpose
+
+| `withMaxToolCallingRoundTrips(int)` | Limits how many tool-calling round 
trips the LLM may perform per request (`0` = unset)
+| `withHallucinatedToolNameStrategy(...)` | Handles requests for tool names 
that do not exist (for example a hallucinated `task_complete` tool)
+| `withToolExecutionErrorHandler(...)` | Converts tool execution exceptions 
into tool result messages for the LLM
+| `withToolArgumentsErrorHandler(...)` | Handles invalid or unparsable tool 
arguments
+| `withCompensateOnToolErrors(Boolean)` | Sends tool errors back to the LLM so 
it can recover instead of failing the exchange
+| `withExecuteToolsConcurrently()` / `withExecuteToolsConcurrently(Executor)` 
| Runs multiple tool calls from one LLM turn in parallel
+|===
+
+._Java-only: recover from a hallucinated tool name_
+[source,java]
+----
+AgentConfiguration configuration = new AgentConfiguration()
+    .withChatModel(chatModel)
+    .withMaxToolCallingRoundTrips(5)
+    .withHallucinatedToolNameStrategy(request ->
+        ToolExecutionResultMessage.from(request, "Unknown tool: " + 
request.name()));
+----
+
+Some LangChain4j builder options (for example `beforeToolExecution`, 
`afterToolExecution`, or `toolSearchStrategy`) are not wrapped individually. 
Use the customizer escape hatch instead:
+
+._Java-only: configure any remaining AiServices builder option_
+[source,java]
+----
+AgentConfiguration configuration = new AgentConfiguration()
+    .withChatModel(chatModel)
+    .withAiServicesCustomizer(builder -> builder
+        .beforeToolExecution(ctx -> log.info("Calling tool {}", 
ctx.toolExecutionRequest().name()))
+        .afterToolExecution(exec -> log.info("Tool finished: {}", 
exec.result())));
+----
+
+The customizer runs in `AbstractAgent.configureBuilder()` after all standard 
Camel wiring (tool providers, MCP, guardrails, RAG, and the first-class 
tool-calling options above) and before `build()` is called.
 
 ==== Concurrent tool execution
 
diff --git 
a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationToolCallingBehaviorTest.java
 
b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationToolCallingBehaviorTest.java
new file mode 100644
index 000000000000..b6294beadb72
--- /dev/null
+++ 
b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationToolCallingBehaviorTest.java
@@ -0,0 +1,208 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.langchain4j.agent.api;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.langchain4j.agent.tool.Tool;
+import dev.langchain4j.agent.tool.ToolExecutionRequest;
+import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.data.message.ToolExecutionResultMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import dev.langchain4j.service.Result;
+import dev.langchain4j.service.tool.ToolErrorHandlerResult;
+import dev.langchain4j.service.tool.ToolExecutionErrorHandler;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Behavioral tests for CAMEL-23928: tool-calling options and the AiServices 
customizer hook wired through
+ * {@link AbstractAgent#configureBuilder(dev.langchain4j.service.AiServices, 
dev.langchain4j.service.tool.ToolProvider)}.
+ */
+class AgentConfigurationToolCallingBehaviorTest {
+
+    private final AtomicInteger chatRound = new AtomicInteger();
+    private final AtomicBoolean beforeToolExecutionInvoked = new 
AtomicBoolean();
+    private final AtomicReference<String> hallucinatedToolName = new 
AtomicReference<>();
+    private final AtomicBoolean toolExecutionErrorHandled = new 
AtomicBoolean();
+
+    @BeforeEach
+    void resetState() {
+        chatRound.set(0);
+        beforeToolExecutionInvoked.set(false);
+        hallucinatedToolName.set(null);
+        toolExecutionErrorHandled.set(false);
+    }
+
+    @Test
+    void hallucinatedToolNameStrategyAllowsAgentToRecover() {
+        ChatModel chatModel = new ChatModel() {
+            @Override
+            public ChatResponse doChat(ChatRequest request) {
+                if (chatRound.getAndIncrement() == 0) {
+                    ToolExecutionRequest hallucinated = 
ToolExecutionRequest.builder()
+                            .id("h1")
+                            .name("task_complete")
+                            .arguments("{}")
+                            .build();
+                    return ChatResponse.builder()
+                            
.aiMessage(AiMessage.builder().toolExecutionRequests(List.of(hallucinated)).build())
+                            .build();
+                }
+                return 
ChatResponse.builder().aiMessage(AiMessage.from("recovered")).build();
+            }
+        };
+
+        AgentConfiguration configuration = new AgentConfiguration()
+                .withChatModel(chatModel)
+                .withMaxToolCallingRoundTrips(3)
+                .withHallucinatedToolNameStrategy(request -> {
+                    hallucinatedToolName.set(request.name());
+                    return ToolExecutionResultMessage.from(request, "Tool not 
found: " + request.name());
+                });
+
+        Agent agent = new AgentWithoutMemory(configuration);
+        Result<String> result = agent.chat(new AiAgentBody<>("complete the 
task"), null);
+
+        assertThat(result.content()).isEqualTo("recovered");
+        assertThat(hallucinatedToolName.get()).isEqualTo("task_complete");
+        assertThat(chatRound.get()).isEqualTo(2);
+    }
+
+    @Test
+    void maxToolCallingRoundTripsIsEnforced() {
+        ChatModel alwaysRequestsTool = new ChatModel() {
+            @Override
+            public ChatResponse doChat(ChatRequest request) {
+                ToolExecutionRequest toolRequest = 
ToolExecutionRequest.builder()
+                        .id("loop")
+                        .name("countItems")
+                        .arguments("{}")
+                        .build();
+                return ChatResponse.builder()
+                        
.aiMessage(AiMessage.builder().toolExecutionRequests(List.of(toolRequest)).build())
+                        .build();
+            }
+        };
+
+        AgentConfiguration configuration = new AgentConfiguration()
+                .withChatModel(alwaysRequestsTool)
+                .withCustomTools(List.of(new CountItemsTool()))
+                .withMaxToolCallingRoundTrips(1);
+
+        Agent agent = new AgentWithoutMemory(configuration);
+
+        assertThatThrownBy(() -> agent.chat(new AiAgentBody<>("count"), null))
+                .isInstanceOf(RuntimeException.class)
+                .hasMessageContaining("tool calling round trips");
+    }
+
+    @Test
+    void toolExecutionErrorHandlerAndCompensationAllowRecovery() {
+        ChatModel chatModel = new ChatModel() {
+            @Override
+            public ChatResponse doChat(ChatRequest request) {
+                if (chatRound.getAndIncrement() == 0) {
+                    ToolExecutionRequest toolRequest = 
ToolExecutionRequest.builder()
+                            .id("f1")
+                            .name("failOperation")
+                            .arguments("{}")
+                            .build();
+                    return ChatResponse.builder()
+                            
.aiMessage(AiMessage.builder().toolExecutionRequests(List.of(toolRequest)).build())
+                            .build();
+                }
+                return 
ChatResponse.builder().aiMessage(AiMessage.from("handled")).build();
+            }
+        };
+
+        ToolExecutionErrorHandler errorHandler = (error, context) -> {
+            toolExecutionErrorHandled.set(true);
+            return ToolErrorHandlerResult.text("tool failed safely");
+        };
+
+        AgentConfiguration configuration = new AgentConfiguration()
+                .withChatModel(chatModel)
+                .withCustomTools(List.of(new FailingTool()))
+                .withMaxToolCallingRoundTrips(3)
+                .withCompensateOnToolErrors(true)
+                .withToolExecutionErrorHandler(errorHandler);
+
+        Agent agent = new AgentWithoutMemory(configuration);
+        Result<String> result = agent.chat(new AiAgentBody<>("run failing 
tool"), null);
+
+        assertThat(result.content()).isEqualTo("handled");
+        assertThat(toolExecutionErrorHandled).isTrue();
+    }
+
+    @Test
+    void aiServicesCustomizerCanConfigureBeforeToolExecution() {
+        ChatModel chatModel = new ChatModel() {
+            @Override
+            public ChatResponse doChat(ChatRequest request) {
+                if (chatRound.getAndIncrement() == 0) {
+                    ToolExecutionRequest toolRequest = 
ToolExecutionRequest.builder()
+                            .id("c1")
+                            .name("countItems")
+                            .arguments("{}")
+                            .build();
+                    return ChatResponse.builder()
+                            
.aiMessage(AiMessage.builder().toolExecutionRequests(List.of(toolRequest)).build())
+                            .build();
+                }
+                return 
ChatResponse.builder().aiMessage(AiMessage.from("done")).build();
+            }
+        };
+
+        AgentConfiguration configuration = new AgentConfiguration()
+                .withChatModel(chatModel)
+                .withCustomTools(List.of(new CountItemsTool()))
+                .withMaxToolCallingRoundTrips(3)
+                .withAiServicesCustomizer(builder -> 
builder.beforeToolExecution(
+                        before -> beforeToolExecutionInvoked.set(true)));
+
+        Agent agent = new AgentWithoutMemory(configuration);
+        Result<String> result = agent.chat(new AiAgentBody<>("count items"), 
null);
+
+        assertThat(result.content()).isEqualTo("done");
+        assertThat(beforeToolExecutionInvoked).isTrue();
+    }
+
+    static class CountItemsTool {
+
+        @Tool(name = "countItems", value = "Returns a fixed count")
+        int countItems() {
+            return 42;
+        }
+    }
+
+    static class FailingTool {
+
+        @Tool(name = "failOperation", value = "Always fails")
+        String failOperation() {
+            throw new RuntimeException("Simulated tool failure");
+        }
+    }
+}
diff --git 
a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc
 
b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc
index e70651121988..5d2fd309ffa9 100644
--- 
a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc
+++ 
b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc
@@ -159,6 +159,48 @@ Agents are configured using the `AgentConfiguration` class 
which provides a flue
 * Retrieval Augmentor (for RAG functionality)
 * Input and Output Guardrails
 * Concurrent tool execution (`withExecuteToolsConcurrently`) for parallel 
Camel route tools and MCP tools within one LLM round trip
+* Tool-calling control: round-trip limits, hallucinated tool handling, and 
error compensation
+* AiServices builder customizer for advanced LangChain4j options
+
+==== Tool-calling options
+
+`AgentConfiguration` exposes the most common LangChain4j `AiServices` 
tool-calling settings as first-class fluent methods. These are applied 
automatically when Camel creates an agent from the configuration bean (inline 
agent mode or a registered `AgentWithMemory` / `AgentWithoutMemory` bean):
+
+[cols="1,2"]
+|===
+| Method | Purpose
+
+| `withMaxToolCallingRoundTrips(int)` | Limits how many tool-calling round 
trips the LLM may perform per request (`0` = unset)
+| `withHallucinatedToolNameStrategy(...)` | Handles requests for tool names 
that do not exist (for example a hallucinated `task_complete` tool)
+| `withToolExecutionErrorHandler(...)` | Converts tool execution exceptions 
into tool result messages for the LLM
+| `withToolArgumentsErrorHandler(...)` | Handles invalid or unparsable tool 
arguments
+| `withCompensateOnToolErrors(Boolean)` | Sends tool errors back to the LLM so 
it can recover instead of failing the exchange
+| `withExecuteToolsConcurrently()` / `withExecuteToolsConcurrently(Executor)` 
| Runs multiple tool calls from one LLM turn in parallel
+|===
+
+._Java-only: recover from a hallucinated tool name_
+[source,java]
+----
+AgentConfiguration configuration = new AgentConfiguration()
+    .withChatModel(chatModel)
+    .withMaxToolCallingRoundTrips(5)
+    .withHallucinatedToolNameStrategy(request ->
+        ToolExecutionResultMessage.from(request, "Unknown tool: " + 
request.name()));
+----
+
+Some LangChain4j builder options (for example `beforeToolExecution`, 
`afterToolExecution`, or `toolSearchStrategy`) are not wrapped individually. 
Use the customizer escape hatch instead:
+
+._Java-only: configure any remaining AiServices builder option_
+[source,java]
+----
+AgentConfiguration configuration = new AgentConfiguration()
+    .withChatModel(chatModel)
+    .withAiServicesCustomizer(builder -> builder
+        .beforeToolExecution(ctx -> log.info("Calling tool {}", 
ctx.toolExecutionRequest().name()))
+        .afterToolExecution(exec -> log.info("Tool finished: {}", 
exec.result())));
+----
+
+The customizer runs in `AbstractAgent.configureBuilder()` after all standard 
Camel wiring (tool providers, MCP, guardrails, RAG, and the first-class 
tool-calling options above) and before `build()` is called.
 
 ==== Concurrent tool execution
 
diff --git 
a/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentAiServicesCustomizerTest.java
 
b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentAiServicesCustomizerTest.java
new file mode 100644
index 000000000000..31c13fa5c228
--- /dev/null
+++ 
b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentAiServicesCustomizerTest.java
@@ -0,0 +1,149 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.langchain4j.agent;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.langchain4j.agent.tool.ToolExecutionRequest;
+import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.data.message.ToolExecutionResultMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.langchain4j.agent.api.AgentConfiguration;
+import org.apache.camel.component.langchain4j.agent.api.AiAgentBody;
+import org.apache.camel.spi.Registry;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for CAMEL-23928: {@link AgentConfiguration} tool-calling 
options and AiServices customizer when
+ * used through the langchain4j-agent component.
+ */
+class LangChain4jAgentAiServicesCustomizerTest extends CamelTestSupport {
+
+    private static final String TAG = "aiservices-customizer";
+
+    private final AtomicInteger chatRound = new AtomicInteger();
+    private final AtomicBoolean customizerInvoked = new AtomicBoolean();
+    private final AtomicReference<String> hallucinatedToolName = new 
AtomicReference<>();
+
+    @BeforeEach
+    void resetState() {
+        chatRound.set(0);
+        customizerInvoked.set(false);
+        hallucinatedToolName.set(null);
+    }
+
+    @Override
+    protected void bindToRegistry(Registry registry) {
+        registry.bind("hallucinationConfig", new AgentConfiguration()
+                .withChatModel(createHallucinationRecoveryModel())
+                .withMaxToolCallingRoundTrips(3)
+                .withHallucinatedToolNameStrategy(request -> {
+                    hallucinatedToolName.set(request.name());
+                    return ToolExecutionResultMessage.from(request, "unknown 
tool: " + request.name());
+                }));
+
+        registry.bind("customizerConfig", new AgentConfiguration()
+                .withChatModel(createSingleToolModel())
+                .withMaxToolCallingRoundTrips(3)
+                .withAiServicesCustomizer(builder -> {
+                    customizerInvoked.set(true);
+                    builder.beforeToolExecution(before -> {
+                    });
+                }));
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:hallucination")
+                        
.to("langchain4j-agent:test?agentConfiguration=#hallucinationConfig&tags=" + 
TAG);
+
+                from("direct:customizer")
+                        
.to("langchain4j-agent:test?agentConfiguration=#customizerConfig&tags=" + TAG);
+
+                from("ai-tool:routeCounter?tags=" + TAG + 
"&description=Route-backed counter")
+                        .setBody(constant("counted"));
+            }
+        };
+    }
+
+    @Test
+    void hallucinatedToolNameStrategyWorksThroughLangchain4jAgentEndpoint() {
+        String response = template.requestBody("direct:hallucination", new 
AiAgentBody<>("finish task"), String.class);
+
+        assertThat(response).isEqualTo("recovered via route");
+        assertThat(hallucinatedToolName.get()).isEqualTo("task_complete");
+    }
+
+    @Test
+    void aiServicesCustomizerIsAppliedWhenUsingAgentConfigurationBean() {
+        String response = template.requestBody("direct:customizer", new 
AiAgentBody<>("count"), String.class);
+
+        assertThat(response).isEqualTo("done");
+        assertThat(customizerInvoked).isTrue();
+    }
+
+    private ChatModel createHallucinationRecoveryModel() {
+        return new ChatModel() {
+            @Override
+            public ChatResponse doChat(ChatRequest request) {
+                if (chatRound.getAndIncrement() == 0) {
+                    ToolExecutionRequest hallucinated = 
ToolExecutionRequest.builder()
+                            .id("h1")
+                            .name("task_complete")
+                            .arguments("{}")
+                            .build();
+                    return ChatResponse.builder()
+                            
.aiMessage(AiMessage.builder().toolExecutionRequests(List.of(hallucinated)).build())
+                            .build();
+                }
+                return 
ChatResponse.builder().aiMessage(AiMessage.from("recovered via route")).build();
+            }
+        };
+    }
+
+    private ChatModel createSingleToolModel() {
+        return new ChatModel() {
+            @Override
+            public ChatResponse doChat(ChatRequest request) {
+                if (chatRound.getAndIncrement() == 0) {
+                    ToolExecutionRequest toolRequest = 
ToolExecutionRequest.builder()
+                            .id("r1")
+                            .name("routeCounter")
+                            .arguments("{}")
+                            .build();
+                    return ChatResponse.builder()
+                            
.aiMessage(AiMessage.builder().toolExecutionRequests(List.of(toolRequest)).build())
+                            .build();
+                }
+                return 
ChatResponse.builder().aiMessage(AiMessage.from("done")).build();
+            }
+        };
+    }
+}

Reply via email to