This is an automated email from the ASF dual-hosted git repository. gnodet pushed a commit to branch fix-camel-23944-camel-langchain4j-agent-toolexecu in repository https://gitbox.apache.org/repos/asf/camel.git
commit 1ba036d2500ec6cb2cbd46c4f2e48b3444c14d43 Author: Guillaume Nodet <[email protected]> AuthorDate: Tue Jul 21 16:41:41 2026 +0000 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]> --- .../agent/LangChain4jAgentProducer.java | 31 ++- ...gChain4jAgentToolExecutorErrorHandlingTest.java | 213 +++++++++++++++++++++ 2 files changed, 238 insertions(+), 6 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 b03386367032..5a430756cb79 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 @@ -52,6 +52,7 @@ import org.apache.camel.component.langchain4j.agent.api.Headers; import org.apache.camel.component.langchain4j.tools.spec.CamelToolExecutorCache; import org.apache.camel.component.langchain4j.tools.spec.CamelToolSpecification; 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; @@ -262,10 +263,16 @@ public class LangChain4jAgentProducer extends DefaultProducer { // Get the existing ToolSpecification from CamelToolSpecification ToolSpecification toolSpecification = camelToolSpec.getToolSpecification(); - // Create a functional tool executor for this specific Camel route + // Create a functional tool executor for this specific Camel route. + // A separate exchange is created for each tool invocation to avoid + // side-effect leakage (headers, body, exceptions) into the calling exchange. ToolExecutor toolExecutor = (toolExecutionRequest, memoryId) -> { LOG.info("Executing Camel route tool: '{}' with arguments: {}", toolName, toolExecutionRequest.arguments()); + // Isolate tool execution in its own exchange so that headers, + // body mutations and exceptions do not leak into the producer exchange. + Exchange toolExchange = ExchangeHelper.createCopy(exchange, true); + try { // Parse JSON arguments if provided String arguments = toolExecutionRequest.arguments(); @@ -299,24 +306,36 @@ public class LangChain4jAgentProducer extends DefaultProducer { } else { headerValue = value.asText(); } - exchange.getMessage().setHeader(name, headerValue); + toolExchange.getMessage().setHeader(name, headerValue); }); } // Set the tool name as a header for route identification - exchange.getMessage().setHeader("CamelToolName", toolName); + toolExchange.getMessage().setHeader("CamelToolName", toolName); // Execute the consumer route - camelToolSpec.getConsumer().getProcessor().process(exchange); + camelToolSpec.getConsumer().getProcessor().process(toolExchange); + + // Check for exchange exceptions stored by the Camel pipeline. + // In Camel, route processors typically catch exceptions and store + // them on the exchange via setException() rather than rethrowing. + // Without this check, LangChain4j never sees the failure and + // ToolExecutionErrorHandler / compensateOnToolErrors never fire. + if (toolExchange.getException() != null) { + throw toolExchange.getException(); + } // Return the result - String result = exchange.getIn().getBody(String.class); + String result = toolExchange.getIn().getBody(String.class); LOG.info("Tool '{}' execution completed successfully", toolName); return result != null ? result : "No result"; } catch (Exception e) { LOG.error("Error executing tool '{}': {}", toolName, e.getMessage(), e); - return String.format("Error executing tool '%s': %s", toolName, e.getMessage()); + // Rethrow so LangChain4j's error handling machinery + // (ToolExecutionErrorHandler, compensateOnToolErrors) can kick in. + throw new RuntimeException( + String.format("Error executing tool '%s': %s", toolName, e.getMessage()), e); } }; 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..b50b0fb65e65 --- /dev/null +++ b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentToolExecutorErrorHandlingTest.java @@ -0,0 +1,213 @@ +/* + * 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.ToolProvider; +import dev.langchain4j.service.tool.ToolProviderRequest; +import dev.langchain4j.service.tool.ToolProviderResult; +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. 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 swallowed exceptions from the route (stored on the exchange via + * {@code setException()}) and returned an error string instead, preventing LangChain4j from seeing the failure. + */ +class LangChain4jAgentToolExecutorErrorHandlingTest extends CamelTestSupport { + + private static final String TOOL_SUCCESS_RESULT = "{\"status\": \"ok\"}"; + + /** Captures the ToolProvider passed by the producer so we can test the ToolExecutor directly. */ + private final AtomicReference<ToolProvider> capturedToolProvider = new AtomicReference<>(); + + /** 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) -> { + capturedToolProvider.set(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("{}") + .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("langchain4j-tools:failingTool?tags=failing" + + "&description=A tool that always fails" + + "¶meter.input=string") + .throwException(new RuntimeException("Simulated tool failure")); + + // Camel route tool that succeeds + from("langchain4j-tools:successTool?tags=success" + + "&description=A tool that always succeeds" + + "¶meter.input=string") + .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. + */ + @Test + void toolExecutorShouldThrowWhenCamelRouteToolFails() { + errorObserved.set(false); + + template.sendBody("direct:agent-with-failing-tool", "test input"); + + // The capturing agent should have observed the error + 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); + + template.sendBody("direct:agent-with-failing-tool", "test input"); + + // 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. + */ + @Test + void toolExecutionShouldNotLeakStateIntoCallingExchange() { + // Send a message with a known body and header + String originalBody = "original body"; + + String response = template.requestBodyAndHeader( + "direct:agent-with-success-tool", + originalBody, + "originalHeader", "originalValue", + String.class); + + // The response should come from the agent, not be polluted by tool execution + assertThat(response).isEqualTo("agent response"); + } +}
