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 8ea839f45875 CAMEL-23952: Support executeToolsConcurrently for
langchain4j-agent
8ea839f45875 is described below
commit 8ea839f4587588da6c51c06fa0b4b022aa78745a
Author: Omar Atie <[email protected]>
AuthorDate: Tue Aug 4 01:56:42 2026 -0700
CAMEL-23952: Support executeToolsConcurrently for langchain4j-agent
Expose executeToolsConcurrently on AgentConfiguration with optional
user-supplied or Camel-managed executor. Uses duplicate() to protect
shared registry beans from mutation. Includes concurrency tests and
component documentation.
Co-authored-by: Cursor <[email protected]>
---
.../catalog/docs/langchain4j-agent-component.adoc | 19 +++
.../camel-ai/camel-langchain4j-agent-api/pom.xml | 5 +
.../langchain4j/agent/api/AbstractAgent.java | 9 ++
.../langchain4j/agent/api/AgentConfiguration.java | 79 ++++++++++
.../agent/api/AgentConfigurationTest.java | 106 ++++++++++++-
.../src/main/docs/langchain4j-agent-component.adoc | 19 +++
.../agent/LangChain4jAgentProducer.java | 28 +++-
...ngChain4jAgentExecuteToolsConcurrentlyTest.java | 168 +++++++++++++++++++++
8 files changed, 431 insertions(+), 2 deletions(-)
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 143a168fd3d4..4afe1aa5b07b 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
@@ -158,6 +158,25 @@ Agents are configured using the `AgentConfiguration` class
which provides a flue
* Chat Memory Provider (for memory-enabled agents)
* 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
+
+==== Concurrent tool execution
+
+When the LLM requests multiple tools in a single turn, LangChain4j can invoke
them in parallel via `AgentConfiguration.withExecuteToolsConcurrently()`. Camel
route tools (`ai-tool:` consumers discovered through `tags`) run each
invocation on an isolated exchange copy, so concurrent execution does not
corrupt the producer exchange.
+
+._Java-only: enable parallel tool calls with a managed Camel executor_
+[source,java]
+----
+AgentConfiguration configuration = new AgentConfiguration()
+ .withChatModel(chatModel)
+ .withExecuteToolsConcurrently();
+
+// When created through langchain4j-agent (agentConfiguration on the endpoint),
+// Camel registers a managed thread pool from ExecutorServiceManager
automatically.
+// Pass your own Executor with withExecuteToolsConcurrently(executor) to
override.
+----
+
+Tool-route header side-effects are not merged back onto the main exchange when
tools run concurrently; only the tool result text is returned to the LLM.
==== Creating an Agent without Memory
diff --git a/components/camel-ai/camel-langchain4j-agent-api/pom.xml
b/components/camel-ai/camel-langchain4j-agent-api/pom.xml
index 3bc1fb80dc59..0a1f9f9bb6b7 100644
--- a/components/camel-ai/camel-langchain4j-agent-api/pom.xml
+++ b/components/camel-ai/camel-langchain4j-agent-api/pom.xml
@@ -64,6 +64,11 @@
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.assertj</groupId>
+ <artifactId>assertj-core</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
</project>
\ No newline at end of file
diff --git
a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java
b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java
index 9c0689f8f51a..34fd61f2a118 100644
---
a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java
+++
b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java
@@ -18,6 +18,7 @@ package org.apache.camel.component.langchain4j.agent.api;
import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.Executor;
import java.util.function.BiPredicate;
import dev.langchain4j.agent.tool.ToolSpecification;
@@ -184,6 +185,14 @@ public abstract class AbstractAgent<S> implements Agent {
if (configuration.getCompensateOnToolErrors() != null) {
builder.compensateOnToolErrors(configuration.getCompensateOnToolErrors());
}
+ if (Boolean.TRUE.equals(configuration.getExecuteToolsConcurrently())) {
+ Executor executor = configuration.getExecuteToolsExecutor();
+ if (executor != null) {
+ builder.executeToolsConcurrently(executor);
+ } else {
+ builder.executeToolsConcurrently();
+ }
+ }
// Custom AiServices builder customizer (escape hatch for any builder
option not directly exposed)
if (configuration.getAiServicesCustomizer() != null) {
diff --git
a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java
b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java
index c813d7454ae5..c311894921c1 100644
---
a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java
+++
b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java
@@ -20,6 +20,8 @@ package org.apache.camel.component.langchain4j.agent.api;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.Executor;
import java.util.function.BiPredicate;
import java.util.function.Consumer;
import java.util.function.Function;
@@ -79,6 +81,8 @@ public class AgentConfiguration {
private ToolExecutionErrorHandler toolExecutionErrorHandler;
private ToolArgumentsErrorHandler toolArgumentsErrorHandler;
private Boolean compensateOnToolErrors;
+ private Boolean executeToolsConcurrently;
+ private Executor executeToolsExecutor;
private Consumer<AiServices<?>> aiServicesCustomizer;
/**
@@ -463,6 +467,81 @@ public class AgentConfiguration {
return this;
}
+ /**
+ * Gets whether concurrent tool execution is enabled for this agent.
+ *
+ * @return {@code true} if enabled, {@code false} if explicitly disabled,
or {@code null} if not configured
+ */
+ public Boolean getExecuteToolsConcurrently() {
+ return executeToolsConcurrently;
+ }
+
+ /**
+ * Gets the executor used for concurrent tool execution, if configured.
+ *
+ * @return the executor, or {@code null} if not configured
+ */
+ public Executor getExecuteToolsExecutor() {
+ return executeToolsExecutor;
+ }
+
+ /**
+ * Enables parallel execution of all tool calls within a single LLM round
trip via LangChain4j's
+ * {@code executeToolsConcurrently()} mode. Requires Camel route tools to
run on isolated exchange copies (see
+ * {@code LangChain4jAgentProducer}).
+ * <p>
+ * When no explicit executor is supplied, the langchain4j-agent component
resolves a managed thread pool from the
+ * Camel {@code ExecutorServiceManager} at startup.
+ *
+ * @return this configuration instance for method chaining
+ */
+ public AgentConfiguration withExecuteToolsConcurrently() {
+ this.executeToolsConcurrently = true;
+ return this;
+ }
+
+ /**
+ * Enables parallel tool execution using the given executor.
+ *
+ * @param executeToolsExecutor the executor for concurrent tool
invocations (must not be {@code null}; use
+ * {@link #withExecuteToolsConcurrently()}
for the managed executor)
+ * @return this configuration instance for method
chaining
+ * @throws NullPointerException if {@code executeToolsExecutor} is {@code
null}
+ */
+ public AgentConfiguration withExecuteToolsConcurrently(Executor
executeToolsExecutor) {
+ this.executeToolsConcurrently = true;
+ this.executeToolsExecutor =
Objects.requireNonNull(executeToolsExecutor, "executeToolsExecutor");
+ return this;
+ }
+
+ /**
+ * Creates a shallow copy of this configuration. Used by the
langchain4j-agent producer to attach a managed tool
+ * executor without mutating registry-held configuration beans.
+ *
+ * @return a new configuration instance with the same settings
+ * @since 4.22
+ */
+ public AgentConfiguration duplicate() {
+ AgentConfiguration copy = new AgentConfiguration();
+ copy.chatModel = chatModel;
+ copy.chatMemoryProvider = chatMemoryProvider;
+ copy.retrievalAugmentor = retrievalAugmentor;
+ copy.inputGuardrailClasses = inputGuardrailClasses;
+ copy.outputGuardrailClasses = outputGuardrailClasses;
+ copy.customTools = customTools;
+ copy.mcpClients = mcpClients;
+ copy.mcpToolProviderFilter = mcpToolProviderFilter;
+ copy.maxToolCallingRoundTrips = maxToolCallingRoundTrips;
+ copy.hallucinatedToolNameStrategy = hallucinatedToolNameStrategy;
+ copy.toolExecutionErrorHandler = toolExecutionErrorHandler;
+ copy.toolArgumentsErrorHandler = toolArgumentsErrorHandler;
+ copy.compensateOnToolErrors = compensateOnToolErrors;
+ copy.executeToolsConcurrently = executeToolsConcurrently;
+ copy.executeToolsExecutor = executeToolsExecutor;
+ copy.aiServicesCustomizer = aiServicesCustomizer;
+ return copy;
+ }
+
/**
* Gets the custom AiServices builder customizer.
*
diff --git
a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationTest.java
b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationTest.java
index 297ac1ccc71e..5f9c45e10e69 100644
---
a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationTest.java
+++
b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationTest.java
@@ -17,23 +17,34 @@
package org.apache.camel.component.langchain4j.agent.api;
import java.io.Serializable;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
import java.util.List;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
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.tool.ToolArgumentsErrorHandler;
import dev.langchain4j.service.tool.ToolExecutionErrorHandler;
import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
-public class AgentConfigurationTest {
+class AgentConfigurationTest {
@Test
public void testParseGuardrailClasses_WithValidClasses() {
@@ -331,6 +342,99 @@ public class AgentConfigurationTest {
assertNotNull(config.getAiServicesCustomizer());
}
+ @Test
+ void duplicateCopiesAllDeclaredInstanceFields() throws Exception {
+ Executor executor = Executors.newSingleThreadExecutor();
+ try {
+ ChatModel chatModel = new ChatModel() {
+ @Override
+ public ChatResponse doChat(ChatRequest request) {
+ return
ChatResponse.builder().aiMessage(AiMessage.from("ok")).build();
+ }
+ };
+ Function<ToolExecutionRequest, ToolExecutionResultMessage> strategy
+ = request -> ToolExecutionResultMessage.from(request,
"unknown");
+ ToolExecutionErrorHandler execHandler = (error, context) -> null;
+ ToolArgumentsErrorHandler argsHandler = (error, context) -> null;
+
+ AgentConfiguration original = new AgentConfiguration()
+ .withChatModel(chatModel)
+ .withMaxToolCallingRoundTrips(11)
+ .withHallucinatedToolNameStrategy(strategy)
+ .withToolExecutionErrorHandler(execHandler)
+ .withToolArgumentsErrorHandler(argsHandler)
+ .withCompensateOnToolErrors(true)
+ .withExecuteToolsConcurrently(executor)
+ .withInputGuardrailClassesArray(new String[] {
"java.lang.String" })
+ .withOutputGuardrailClassesArray(new String[] {
"java.io.Serializable" })
+ .withCustomTools(List.of("custom-tool"))
+ .withAiServicesCustomizer(builder -> {
+ });
+
+ AgentConfiguration copy = original.duplicate();
+
+ for (Field field : AgentConfiguration.class.getDeclaredFields()) {
+ int modifiers = field.getModifiers();
+ if (Modifier.isStatic(modifiers)) {
+ continue;
+ }
+ field.setAccessible(true);
+ assertThat(field.get(copy))
+ .as("duplicate() should copy field %s",
field.getName())
+ .isEqualTo(field.get(original));
+ }
+ } finally {
+ ((ExecutorService) executor).shutdownNow();
+ }
+ }
+
+ @Test
+ void withExecuteToolsConcurrentlyRejectsNullExecutor() {
+ assertThatThrownBy(() -> new
AgentConfiguration().withExecuteToolsConcurrently((Executor) null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("executeToolsExecutor");
+ }
+
+ @Test
+ void testDuplicateCopiesExecuteToolsConcurrentlySettings() {
+ Executor executor = Executors.newSingleThreadExecutor();
+ try {
+ AgentConfiguration original = new AgentConfiguration()
+ .withExecuteToolsConcurrently(executor)
+ .withMaxToolCallingRoundTrips(3);
+
+ AgentConfiguration copy = original.duplicate();
+
+ assertThat(copy.getExecuteToolsConcurrently()).isTrue();
+ assertThat(copy.getExecuteToolsExecutor()).isSameAs(executor);
+ assertThat(copy.getMaxToolCallingRoundTrips()).isEqualTo(3);
+ } finally {
+ ((ExecutorService) executor).shutdownNow();
+ }
+ }
+
+ @Test
+ void testExecuteToolsConcurrently() {
+ AgentConfiguration config = new AgentConfiguration();
+ assertThat(config.getExecuteToolsConcurrently()).isNull();
+ assertThat(config.getExecuteToolsExecutor()).isNull();
+
+ AgentConfiguration enabled = config.withExecuteToolsConcurrently();
+ assertThat(enabled).isSameAs(config);
+ assertThat(config.getExecuteToolsConcurrently()).isTrue();
+ assertThat(config.getExecuteToolsExecutor()).isNull();
+
+ Executor executor = Executors.newSingleThreadExecutor();
+ try {
+ AgentConfiguration withExecutor =
config.withExecuteToolsConcurrently(executor);
+ assertThat(withExecutor).isSameAs(config);
+ assertThat(config.getExecuteToolsConcurrently()).isTrue();
+ assertThat(config.getExecuteToolsExecutor()).isSameAs(executor);
+ } finally {
+ ((ExecutorService) executor).shutdownNow();
+ }
+ }
+
@Test
public void testFluentChaining() {
ToolExecutionErrorHandler execHandler = (error, context) -> null;
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 143a168fd3d4..4afe1aa5b07b 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
@@ -158,6 +158,25 @@ Agents are configured using the `AgentConfiguration` class
which provides a flue
* Chat Memory Provider (for memory-enabled agents)
* 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
+
+==== Concurrent tool execution
+
+When the LLM requests multiple tools in a single turn, LangChain4j can invoke
them in parallel via `AgentConfiguration.withExecuteToolsConcurrently()`. Camel
route tools (`ai-tool:` consumers discovered through `tags`) run each
invocation on an isolated exchange copy, so concurrent execution does not
corrupt the producer exchange.
+
+._Java-only: enable parallel tool calls with a managed Camel executor_
+[source,java]
+----
+AgentConfiguration configuration = new AgentConfiguration()
+ .withChatModel(chatModel)
+ .withExecuteToolsConcurrently();
+
+// When created through langchain4j-agent (agentConfiguration on the endpoint),
+// Camel registers a managed thread pool from ExecutorServiceManager
automatically.
+// Pass your own Executor with withExecuteToolsConcurrently(executor) to
override.
+----
+
+Tool-route header side-effects are not merged back onto the main exchange when
tools run concurrently; only the tool result text is returned to the LLM.
==== Creating an Agent without Memory
diff --git
a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java
b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java
index 63d98402041c..197481eb9054 100644
---
a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java
+++
b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java
@@ -26,6 +26,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.ExecutorService;
import java.util.function.BiPredicate;
import java.util.stream.Collectors;
@@ -61,6 +62,7 @@ import
org.apache.camel.component.langchain4j.agent.api.AgentWithoutMemory;
import org.apache.camel.component.langchain4j.agent.api.AiAgentBody;
import org.apache.camel.component.langchain4j.agent.api.CompositeToolProvider;
import org.apache.camel.component.langchain4j.agent.api.Headers;
+import org.apache.camel.spi.ThreadPoolProfile;
import org.apache.camel.support.DefaultProducer;
import org.apache.camel.support.ExchangeHelper;
import org.apache.camel.support.ResourceHelper;
@@ -76,6 +78,7 @@ public class LangChain4jAgentProducer extends DefaultProducer
{
private AgentFactory agentFactory;
private Agent agent;
private List<McpClient> materializedMcpClients;
+ private ExecutorService managedToolExecutionExecutor;
public LangChain4jAgentProducer(LangChain4jAgentEndpoint endpoint) {
super(endpoint);
@@ -114,7 +117,8 @@ public class LangChain4jAgentProducer extends
DefaultProducer {
if (endpoint.getConfiguration().getAgent() != null) {
agent = endpoint.getConfiguration().getAgent();
} else if (endpoint.getConfiguration().getAgentConfiguration() !=
null) {
- AgentConfiguration agentConfiguration =
endpoint.getConfiguration().getAgentConfiguration();
+ AgentConfiguration agentConfiguration =
endpoint.getConfiguration().getAgentConfiguration().duplicate();
+ resolveExecuteToolsConcurrentlyExecutor(agentConfiguration);
agent = agentConfiguration.getChatMemoryProvider() != null
? new AgentWithMemory(agentConfiguration)
: new AgentWithoutMemory(agentConfiguration);
@@ -164,6 +168,24 @@ public class LangChain4jAgentProducer extends
DefaultProducer {
}
}
+ /**
+ * When concurrent tool execution is enabled without an explicit executor,
register a Camel-managed thread pool so
+ * tool parallelism uses the framework lifecycle and naming conventions.
+ */
+ private void resolveExecuteToolsConcurrentlyExecutor(AgentConfiguration
agentConfiguration) {
+ if
(!Boolean.TRUE.equals(agentConfiguration.getExecuteToolsConcurrently())) {
+ return;
+ }
+ if (agentConfiguration.getExecuteToolsExecutor() != null) {
+ return;
+ }
+ ThreadPoolProfile profile =
endpoint.getCamelContext().getExecutorServiceManager().getDefaultThreadPoolProfile();
+ managedToolExecutionExecutor =
endpoint.getCamelContext().getExecutorServiceManager()
+ .newThreadPool(this, "LangChain4jAgentToolExecution", profile);
+
agentConfiguration.withExecuteToolsConcurrently(managedToolExecutionExecutor);
+ LOG.debug("Registered Camel-managed executor for concurrent
LangChain4j tool execution");
+ }
+
/**
* Creates a composed tool provider that aggregates tools from all
configured sources: Camel route tools (via tags)
* and MCP tools (via endpoint-level mcpClients and mcpServers
configuration).
@@ -580,6 +602,10 @@ public class LangChain4jAgentProducer extends
DefaultProducer {
}
materializedMcpClients = null;
}
+ if (managedToolExecutionExecutor != null) {
+
endpoint.getCamelContext().getExecutorServiceManager().shutdownGraceful(managedToolExecutionExecutor);
+ managedToolExecutionExecutor = null;
+ }
super.doStop();
}
}
diff --git
a/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentExecuteToolsConcurrentlyTest.java
b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentExecuteToolsConcurrentlyTest.java
new file mode 100644
index 000000000000..90fe96527fab
--- /dev/null
+++
b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentExecuteToolsConcurrentlyTest.java
@@ -0,0 +1,168 @@
+/*
+ * 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.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import dev.langchain4j.agent.tool.ToolExecutionRequest;
+import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import org.apache.camel.Exchange;
+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.impl.DefaultCamelContext;
+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;
+
+/**
+ * Tests for CAMEL-23952: {@link
AgentConfiguration#withExecuteToolsConcurrently()} and exchange-safe Camel
route tools.
+ */
+class LangChain4jAgentExecuteToolsConcurrentlyTest extends CamelTestSupport {
+
+ private static final String TAG = "concurrent-tools";
+
+ private final TwoToolRoundTripChatModel chatModel = new
TwoToolRoundTripChatModel();
+ private final AtomicInteger chatRound = new AtomicInteger();
+ private CountDownLatch bothToolsEntered = new CountDownLatch(2);
+ private final AtomicInteger inFlight = new AtomicInteger();
+ private final AtomicInteger maxConcurrent = new AtomicInteger();
+
+ @BeforeEach
+ void resetConcurrencyState() {
+ chatRound.set(0);
+ bothToolsEntered = new CountDownLatch(2);
+ maxConcurrent.set(0);
+ inFlight.set(0);
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:agent")
+
.to("langchain4j-agent:test?agentConfiguration=#agentConfig&tags=" + TAG);
+
+ from("ai-tool:slowToolA?tags=" + TAG + "&description=Slow tool
A")
+ .process(exchange -> recordConcurrentEntry("A",
exchange));
+
+ from("ai-tool:slowToolB?tags=" + TAG + "&description=Slow tool
B")
+ .process(exchange -> recordConcurrentEntry("B",
exchange));
+ }
+ };
+ }
+
+ @Override
+ protected void bindToRegistry(Registry registry) {
+ registry.bind("agentConfig", new AgentConfiguration()
+ .withChatModel(chatModel)
+ .withExecuteToolsConcurrently()
+ .withMaxToolCallingRoundTrips(5));
+ }
+
+ @Test
+ void concurrentToolExecutionRunsBothToolsInParallel() throws Exception {
+ String response = template.requestBody("direct:agent", new
AiAgentBody<>("run tools"), String.class);
+
+ assertThat(response).isEqualTo("done");
+ assertThat(bothToolsEntered.await(10, TimeUnit.SECONDS))
+ .as("both tools should be invoked")
+ .isTrue();
+ assertThat(maxConcurrent.get())
+ .as("tools should overlap when executeToolsConcurrently is
enabled")
+ .isGreaterThanOrEqualTo(2);
+ }
+
+ @Test
+ void producerResolvesCamelManagedExecutorWhenNoneConfigured() throws
Exception {
+ try (DefaultCamelContext ctx = new DefaultCamelContext()) {
+ ChatModel noopModel = new ChatModel() {
+ @Override
+ public ChatResponse doChat(ChatRequest request) {
+ return
ChatResponse.builder().aiMessage(AiMessage.from("ok")).build();
+ }
+ };
+ AgentConfiguration config = new AgentConfiguration()
+ .withChatModel(noopModel)
+ .withExecuteToolsConcurrently();
+ ctx.getRegistry().bind("cfg", config);
+
+ ctx.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("direct:x").to("langchain4j-agent:test?agentConfiguration=#cfg");
+ }
+ });
+
+ ctx.start();
+
+ assertThat(config.getExecuteToolsExecutor())
+ .as("registry AgentConfiguration must not be mutated with
a managed executor")
+ .isNull();
+ }
+ }
+
+ @Test
+ void managedExecutorWorksAfterContextStopAndRestart() throws Exception {
+ context.stop();
+ context.getRegistry().bind("agentConfig", new AgentConfiguration()
+ .withChatModel(chatModel)
+ .withExecuteToolsConcurrently()
+ .withMaxToolCallingRoundTrips(5));
+ context.start();
+
+ String response = template.requestBody("direct:agent", new
AiAgentBody<>("run tools"), String.class);
+
+ assertThat(response).isEqualTo("done");
+ }
+
+ private void recordConcurrentEntry(String label, Exchange exchange) throws
InterruptedException {
+ int concurrent = inFlight.incrementAndGet();
+ maxConcurrent.updateAndGet(current -> Math.max(current, concurrent));
+ bothToolsEntered.countDown();
+ assertThat(bothToolsEntered.await(10, TimeUnit.SECONDS))
+ .as("both tools should enter before either completes")
+ .isTrue();
+ inFlight.decrementAndGet();
+ exchange.getMessage().setBody(label);
+ }
+
+ private final class TwoToolRoundTripChatModel implements ChatModel {
+ @Override
+ public ChatResponse doChat(ChatRequest request) {
+ if (chatRound.getAndIncrement() == 0) {
+ List<ToolExecutionRequest> requests = List.of(
+
ToolExecutionRequest.builder().id("a").name("slowToolA").arguments("{}").build(),
+
ToolExecutionRequest.builder().id("b").name("slowToolB").arguments("{}").build());
+ return ChatResponse.builder()
+
.aiMessage(AiMessage.builder().toolExecutionRequests(requests).build())
+ .build();
+ }
+ return
ChatResponse.builder().aiMessage(AiMessage.from("done")).build();
+ }
+ }
+}