This is an automated email from the ASF dual-hosted git repository.
gnodet 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 84e86ea8bec4 CAMEL-23944: Fix ToolExecutionErrorHandler never invoked
for Camel route tools
84e86ea8bec4 is described below
commit 84e86ea8bec45ecc0944b885409d250156ce33c8
Author: Guillaume Nodet <[email protected]>
AuthorDate: Thu Jul 23 11:29:18 2026 +0200
CAMEL-23944: Fix ToolExecutionErrorHandler never invoked for Camel route
tools
Co-authored-by: Claude Opus 4.6 <[email protected]>
* CAMEL-23944: Fix ToolExecutionErrorHandler never invoked for Camel route
tools in agent
The ToolExecutor in LangChain4jAgentProducer.createCamelToolProvider had two
bugs that prevented LangChain4j's error handling from firing when a Camel
route tool failed:
1. After process(exchange), the code did not check exchange.getException().
In Camel, route processors store exceptions on the exchange instead of
rethrowing, so the exception was silently ignored and the error handlers
(ToolExecutionErrorHandler, compensateOnToolErrors) never fired.
2. The catch block returned an error string instead of rethrowing, which
made LangChain4j treat the failure as a successful tool result.
3. The ToolExecutor reused the live producer exchange, leaking tool
side-effects (headers, body, exceptions) into the calling exchange.
Fix: create an isolated exchange copy for each tool invocation, check for
exchange exceptions after processing, and rethrow so LangChain4j's error
handling machinery kicks in.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* CAMEL-23944: Improve reproducer test to properly demonstrate the bug
Wrap sendBody calls in try-catch in the failing-tool tests so the
assertions on errorObserved/capturedErrorMessage are reached regardless
of exchange-level exception propagation. This ensures:
- Without the fix: tests fail with assertion errors (errorObserved=false)
proving the ToolExecutor swallows exceptions
- With the fix: tests pass (errorObserved=true) proving the ToolExecutor
correctly rethrows for LangChain4j error handlers
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* CAMEL-23944: Rebase fix onto new AiToolExecutor and address review
feedback
Re-targets the CAMEL-23944 fix at the new code structure after CAMEL-23382
moved tool execution from inline ToolExecutor in LangChain4jAgentProducer
to the shared AiToolExecutor.
Two remaining bugs on current main:
1. Exception swallowed as string — toToolResponse() returned "Tool execution
failed" on ExecutionError, making LangChain4j believe the tool succeeded.
Fix: rethrow using RuntimeCamelException.wrapRuntimeCamelException() so
ToolExecutionErrorHandler / compensateOnToolErrors can fire.
2. Shared exchange — addToolToResult() passed the live producer exchange to
AiToolExecutor.execute(), leaking tool side-effects (headers, body) into
the calling exchange. Fix: ExchangeHelper.createCopy(exchange, true)
isolates each tool invocation.
Review feedback addressed:
- Use RuntimeCamelException instead of plain RuntimeException (reviewer
@oscerd)
- Fix toolExecutionShouldNotLeakStateIntoCallingExchange to assert original
headers survive and tool argument/side-effect headers do not leak
- Add failingToolExecutionShouldNotLeakStateIntoCallingExchange for failure
path
- Migrate tool URIs from langchain4j-tools: to ai-tool:
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* CAMEL-23944: Remove unused ToolProvider import from test
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* CAMEL-23944: Add upgrade guide entry for exchange isolation behavior
change
Document that tool invocations now run on isolated exchange copies, so
tool headers (arguments, side-effects, CamelToolName) no longer leak
into the calling exchange. Also document that tool execution errors are
now rethrown as RuntimeCamelException.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
.../agent/LangChain4jAgentProducer.java | 21 +-
...gChain4jAgentToolExecutorErrorHandlingTest.java | 277 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_22.adoc | 21 ++
3 files changed, 316 insertions(+), 3 deletions(-)
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 a390f9f811df..139ff4ad55a1 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
@@ -46,6 +46,7 @@ import dev.langchain4j.service.tool.ToolProviderRequest;
import dev.langchain4j.service.tool.ToolProviderResult;
import org.apache.camel.Exchange;
import org.apache.camel.Message;
+import org.apache.camel.RuntimeCamelException;
import org.apache.camel.component.ai.tool.AiToolExecutor;
import org.apache.camel.component.ai.tool.AiToolRegistry;
import org.apache.camel.component.ai.tool.AiToolResult;
@@ -60,6 +61,7 @@ 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.support.DefaultProducer;
+import org.apache.camel.support.ExchangeHelper;
import org.apache.camel.support.ResourceHelper;
import org.apache.camel.util.ObjectHelper;
import org.slf4j.Logger;
@@ -271,6 +273,12 @@ public class LangChain4jAgentProducer extends
DefaultProducer {
* Registers a single tool with the langchain4j runtime. Creates a {@link
ToolExecutor} that converts the
* langchain4j-specific {@link ToolExecutionRequest} into a
framework-agnostic {@code Map<String, Object>} and
* delegates execution to {@link AiToolExecutor}.
+ *
+ * <p>
+ * Each tool invocation runs on an isolated exchange copy so that headers,
body mutations and exceptions from the
+ * tool route do not leak into the calling producer exchange. This is
required because langchain4j can execute tools
+ * concurrently, and because the agent's {@code Result<String>} carries
the response — the original exchange must
+ * stay clean.
*/
private void addToolToResult(
ToolProviderResult.Builder resultBuilder,
@@ -283,7 +291,11 @@ public class LangChain4jAgentProducer extends
DefaultProducer {
if (arguments == null) {
return "Invalid arguments: could not parse the provided JSON
arguments";
}
- AiToolResult result = AiToolExecutor.execute(spec, arguments,
exchange);
+ // Isolate each tool invocation in its own exchange copy so that
+ // headers, body mutations and exceptions do not leak into the
+ // calling producer exchange (CAMEL-23944).
+ Exchange toolExchange = ExchangeHelper.createCopy(exchange, true);
+ AiToolResult result = AiToolExecutor.execute(spec, arguments,
toolExchange);
return toToolResponse(spec.getName(), result);
};
@@ -317,8 +329,11 @@ public class LangChain4jAgentProducer extends
DefaultProducer {
LOG.warn("Tool '{}' argument error: {}", toolName,
error.message(), error.cause());
return "Invalid arguments: " + error.message();
} else if (result instanceof AiToolResult.ExecutionError error) {
- LOG.warn("Tool '{}' execution error: {}", toolName,
error.message(), error.cause());
- return "Tool execution failed";
+ // Rethrow so LangChain4j's error handling machinery
+ // (ToolExecutionErrorHandler, compensateOnToolErrors) can fire.
+ // Use RuntimeCamelException (the Camel idiom) so downstream error
+ // handlers can type-match the real cause without unwrapping
(CAMEL-23944).
+ throw
RuntimeCamelException.wrapRuntimeCamelException(error.cause());
}
return "Tool execution failed";
}
diff --git
a/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentToolExecutorErrorHandlingTest.java
b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentToolExecutorErrorHandlingTest.java
new file mode 100644
index 000000000000..1a140252bcce
--- /dev/null
+++
b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentToolExecutorErrorHandlingTest.java
@@ -0,0 +1,277 @@
+/*
+ * 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.AtomicReference;
+
+import dev.langchain4j.agent.tool.ToolExecutionRequest;
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.invocation.InvocationContext;
+import dev.langchain4j.service.Result;
+import dev.langchain4j.service.tool.AiServiceTool;
+import dev.langchain4j.service.tool.ToolProviderRequest;
+import dev.langchain4j.service.tool.ToolProviderResult;
+import org.apache.camel.Exchange;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.langchain4j.agent.api.Agent;
+import org.apache.camel.spi.Registry;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests that the ToolExecutor created by LangChain4jAgentProducer for Camel
route tools correctly propagates exceptions
+ * and isolates tool execution state. This is the reproducer for CAMEL-23944:
when a Camel route tool throws, the
+ * exception must be rethrown by the ToolExecutor so that LangChain4j's
ToolExecutionErrorHandler /
+ * compensateOnToolErrors can fire.
+ *
+ * <p>
+ * Before the fix, the ToolExecutor had two bugs:
+ * <ol>
+ * <li>Exceptions from tool routes were swallowed and returned as a plain
string ("Tool execution failed"), making
+ * LangChain4j believe the tool succeeded.</li>
+ * <li>The live producer exchange was shared with tool execution, causing tool
side-effects (headers, body, exceptions)
+ * to leak into the calling exchange.</li>
+ * </ol>
+ */
+class LangChain4jAgentToolExecutorErrorHandlingTest extends CamelTestSupport {
+
+ private static final String TOOL_SUCCESS_RESULT = "{\"status\": \"ok\"}";
+
+ /** Tracks whether a tool execution error was observed by the agent. */
+ private final AtomicBoolean errorObserved = new AtomicBoolean(false);
+
+ /** Tracks the successful tool result captured by the agent. */
+ private final AtomicReference<String> capturedToolResult = new
AtomicReference<>();
+
+ /** Captures the error message when a tool execution fails. */
+ private final AtomicReference<String> capturedErrorMessage = new
AtomicReference<>();
+
+ private ToolProviderRequest createToolProviderRequest() {
+ return ToolProviderRequest.builder()
+ .invocationContext(InvocationContext.builder().build())
+ .userMessage(UserMessage.from("test"))
+ .build();
+ }
+
+ @Override
+ protected void bindToRegistry(Registry registry) {
+ // A custom Agent that captures the ToolProvider and exercises the
ToolExecutor directly.
+ // This avoids needing a real LLM while still testing the producer's
tool execution path.
+ Agent capturingAgent = (body, toolProvider) -> {
+
+ if (toolProvider != null) {
+ ToolProviderResult providerResult =
toolProvider.provideTools(createToolProviderRequest());
+
+ List<AiServiceTool> aiTools = providerResult.aiServiceTools();
+ for (AiServiceTool aiTool : aiTools) {
+ ToolExecutionRequest request =
ToolExecutionRequest.builder()
+ .name(aiTool.toolSpecification().name())
+ .arguments("{\"input\": \"test value\"}")
+ .build();
+
+ try {
+ String result = aiTool.toolExecutor().execute(request,
null);
+ capturedToolResult.set(result);
+ } catch (Exception e) {
+ errorObserved.set(true);
+ capturedErrorMessage.set(e.getMessage());
+ }
+ }
+ }
+
+ return Result.<String> builder()
+ .content("agent response")
+ .build();
+ };
+
+ registry.bind("capturingAgent", capturingAgent);
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // Producer route that uses the capturing agent with
failing-tool tag
+ from("direct:agent-with-failing-tool")
+
.to("langchain4j-agent:test?agent=#capturingAgent&tags=failing");
+
+ // Producer route that uses the capturing agent with
success-tool tag
+ from("direct:agent-with-success-tool")
+
.to("langchain4j-agent:test?agent=#capturingAgent&tags=success");
+
+ // Camel route tool that throws an exception
+ from("ai-tool:failingTool?tags=failing"
+ + "&description=A tool that always fails"
+ + "¶meter.input=string")
+ .throwException(new RuntimeException("Simulated tool
failure"));
+
+ // Camel route tool that succeeds and sets a side-effect header
+ from("ai-tool:successTool?tags=success"
+ + "&description=A tool that always succeeds"
+ + "¶meter.input=string")
+ .setHeader("toolSideEffect", constant("leaked"))
+ .setBody(constant(TOOL_SUCCESS_RESULT));
+ }
+ };
+ }
+
+ /**
+ * CAMEL-23944 reproducer: verifies that the ToolExecutor throws when a
Camel route tool fails, so that
+ * LangChain4j's error handling (ToolExecutionErrorHandler,
compensateOnToolErrors) can fire.
+ *
+ * <p>
+ * Before the fix, the ToolExecutor caught the exception and returned an
error string, making LangChain4j believe
+ * the tool succeeded. The sendBody call is wrapped in try-catch because
the producer exchange may or may not
+ * propagate the exception (depending on exchange isolation); we only care
about whether the ToolExecutor itself
+ * threw inside the capturing agent.
+ */
+ @Test
+ void toolExecutorShouldThrowWhenCamelRouteToolFails() {
+ errorObserved.set(false);
+
+ try {
+ template.sendBody("direct:agent-with-failing-tool", "test input");
+ } catch (Exception e) {
+ // Ignored — we are testing the ToolExecutor behavior, not the
exchange propagation
+ }
+
+ // The capturing agent should have observed the error thrown by the
ToolExecutor
+ assertThat(errorObserved.get())
+ .as("ToolExecutor should throw when Camel route tool fails, "
+ + "so LangChain4j error handlers can fire (CAMEL-23944)")
+ .isTrue();
+ }
+
+ /**
+ * Verifies that a successful tool execution still works correctly after
the fix.
+ */
+ @Test
+ void toolExecutorShouldReturnResultWhenCamelRouteToolSucceeds() {
+ errorObserved.set(false);
+ capturedToolResult.set(null);
+
+ template.sendBody("direct:agent-with-success-tool", "test input");
+
+ // The capturing agent should NOT have observed any error
+ assertThat(errorObserved.get())
+ .as("ToolExecutor should not throw when Camel route tool
succeeds")
+ .isFalse();
+
+ // The tool should have returned the expected result
+ assertThat(capturedToolResult.get())
+ .as("ToolExecutor should return the route result body")
+ .isEqualTo(TOOL_SUCCESS_RESULT);
+ }
+
+ /**
+ * Verifies that the ToolExecutor propagates the original exception
message from a failing Camel route. The error
+ * message must contain the original cause so that LangChain4j's
ToolExecutionErrorHandler receives meaningful
+ * diagnostics.
+ */
+ @Test
+ void toolExecutorShouldPropagateExceptionMessageFromFailingRoute() {
+ capturedErrorMessage.set(null);
+
+ try {
+ template.sendBody("direct:agent-with-failing-tool", "test input");
+ } catch (Exception e) {
+ // Ignored — we are testing the ToolExecutor error message, not
the exchange propagation
+ }
+
+ // The exception message should contain the original failure cause
+ assertThat(capturedErrorMessage.get())
+ .as("Exception message should contain the original route
failure reason")
+ .isNotNull()
+ .contains("Simulated tool failure");
+ }
+
+ /**
+ * Verifies that tool execution does not leak state (headers, body) into
the calling exchange. Each tool invocation
+ * should use an isolated exchange copy.
+ *
+ * <p>
+ * Without exchange isolation, AiToolExecutor sets tool argument headers
(like "input") and the tool route may set
+ * additional side-effect headers (like "toolSideEffect") directly on the
live producer exchange. This test verifies
+ * that the calling exchange's original headers survive and that no
tool-related headers leak through.
+ */
+ @Test
+ void toolExecutionShouldNotLeakStateIntoCallingExchange() {
+ Exchange result = template.request("direct:agent-with-success-tool",
exchange -> {
+ exchange.getMessage().setBody("original body");
+ exchange.getMessage().setHeader("originalHeader", "originalValue");
+ });
+
+ // The response body comes from the agent (via result.content()),
+ // not from the tool route
+ assertThat(result.getMessage().getBody(String.class))
+ .as("Response should come from the agent, not the tool route")
+ .isEqualTo("agent response");
+
+ // The caller's original header must survive tool execution
+ assertThat(result.getMessage().getHeader("originalHeader"))
+ .as("Original header should survive tool execution")
+ .isEqualTo("originalValue");
+
+ // Tool argument headers must NOT leak into the calling exchange.
+ // AiToolExecutor sets each tool argument as an exchange header (e.g.
"input");
+ // with exchange isolation, these stay on the copy.
+ assertThat(result.getMessage().getHeader("input"))
+ .as("Tool argument header 'input' should not leak into calling
exchange")
+ .isNull();
+
+ // Side-effect headers set by the tool route must NOT leak.
+ // The success tool route sets "toolSideEffect" = "leaked"; with
exchange
+ // isolation, this stays on the copy.
+ assertThat(result.getMessage().getHeader("toolSideEffect"))
+ .as("Tool route side-effect header should not leak into
calling exchange")
+ .isNull();
+ }
+
+ /**
+ * Verifies that a failing tool also does not leak state into the calling
exchange. The exchange isolation must be
+ * unconditional — it must happen for both success and failure paths.
+ */
+ @Test
+ void failingToolExecutionShouldNotLeakStateIntoCallingExchange() {
+ Exchange result = template.request("direct:agent-with-failing-tool",
exchange -> {
+ exchange.getMessage().setBody("original body");
+ exchange.getMessage().setHeader("originalHeader", "originalValue");
+ });
+
+ // The response body comes from the agent (the capturing agent always
+ // returns "agent response" regardless of tool success/failure)
+ assertThat(result.getMessage().getBody(String.class))
+ .as("Response should come from the agent even after tool
failure")
+ .isEqualTo("agent response");
+
+ // The caller's original header must survive even when the tool fails
+ assertThat(result.getMessage().getHeader("originalHeader"))
+ .as("Original header should survive failing tool execution")
+ .isEqualTo("originalValue");
+
+ // Tool argument headers must NOT leak even on the failure path
+ assertThat(result.getMessage().getHeader("input"))
+ .as("Tool argument header 'input' should not leak on failure
path")
+ .isNull();
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
index 9a0b63d686ae..bc5d20fdbd83 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
@@ -218,6 +218,27 @@ Add the `camel-ai-tool` dependency to your project:
</dependency>
----
+==== Exchange isolation for Camel route tools
+
+Each Camel route tool invocation now runs on an isolated exchange copy.
Previously, the live
+producer exchange was shared with the tool route, which caused tool
side-effects (headers, body
+mutations) to leak into the calling exchange and introduced a data race when
LangChain4j executed
+tools concurrently.
+
+After this change:
+
+* Headers set by tool argument mapping (e.g. `input`) no longer appear on the
calling exchange
+ after the agent returns.
+* Headers set inside the tool route (e.g. custom side-effect headers) no
longer leak.
+* The `CamelToolName` header is no longer visible on the calling exchange.
+* Tool execution errors are rethrown as `RuntimeCamelException` so that
LangChain4j's
+ `ToolExecutionErrorHandler` and `compensateOnToolErrors` fire correctly.
Previously, errors were
+ swallowed and returned as a plain string, making LangChain4j believe the
tool succeeded.
+
+If your code reads tool-related headers from the calling exchange after agent
invocation,
+you will need to adjust it. The agent's `Result<String>` (and the new
`CamelLangChain4jAgentToolExecutions`
+header) is the intended way to observe tool execution results.
+
=== camel-langchain4j-chat
The helper classes `OpenAiChatLanguageModelBuilder` and
`HugginFaceChatLanguageModelBuilder` have been removed.