From 0b2955e322e5e313ccc3bf37e6e7697454bfdb37 Mon Sep 17 00:00:00 2001 From: Karol Date: Tue, 16 Jun 2026 22:30:55 +0200 Subject: [PATCH] CAMEL-23704: Use isolated exchange copy per tool in langchain4j-tools When the LLM requests multiple tool invocations within a single request, each tool is now invoked on its own independent copy of the original incoming exchange (a baseline snapshot reused across all iterations), instead of sharing a single exchange across all tools. This prevents the message body, argument headers and exceptions produced by one tool from leaking into sibling tool invocations. Each tool's outcome is copied back onto the parent exchange so the producer output still reflects the (last) tool result together with its declared argument headers. --- .../tools/LangChain4jToolsProducer.java | 24 +++++++++++-------- .../LangChain4jToolMultipleCallsTest.java | 17 ++++++++----- .../pages/camel-4x-upgrade-guide-4_21.adoc | 12 ++++++++++ 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/components/camel-ai/camel-langchain4j-tools/src/main/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsProducer.java b/components/camel-ai/camel-langchain4j-tools/src/main/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsProducer.java index 395f96cfce29d..f49ac949559c7 100644 --- a/components/camel-ai/camel-langchain4j-tools/src/main/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsProducer.java +++ b/components/camel-ai/camel-langchain4j-tools/src/main/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsProducer.java @@ -46,6 +46,7 @@ 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.util.ObjectHelper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -119,6 +120,8 @@ private String toolsChat(List chatMessages, Exchange exchange) { return null; } + final Exchange baseline = ExchangeHelper.createCopy(exchange, true); + // First talk to the model to get the tools to be called int i = 0; do { @@ -129,7 +132,7 @@ private String toolsChat(List chatMessages, Exchange exchange) { } // Only invoke the tools ... the response will be computed on the next loop - invokeTools(chatMessages, exchange, response, toolPair); + invokeTools(chatMessages, exchange, response, toolPair, baseline); LOG.debug("Finished iteration {}", i); i++; } while (true); @@ -153,7 +156,8 @@ private boolean isDoneExecuting(Response response) { } private void invokeTools( - List chatMessages, Exchange exchange, Response response, ToolPair toolPair) { + List chatMessages, Exchange exchange, Response response, ToolPair toolPair, + Exchange baseline) { int i = 0; List toolExecutionRequests = response.content().toolExecutionRequests(); for (ToolExecutionRequest toolExecutionRequest : toolExecutionRequests) { @@ -167,13 +171,11 @@ private void invokeTools( continue; } - // Reset route stop flag from previous tool invocation to prevent - // stop() EIP in one tool from short-circuiting subsequent tools - exchange.setRouteStop(false); - final CamelToolSpecification camelToolSpecification = toolPair.callableTools().stream() .filter(c -> c.getToolSpecification().name().equals(toolName)).findFirst().get(); + final Exchange toolExchange = ExchangeHelper.createCopy(baseline, true); + try { TypeConverter typeConverter = endpoint.getCamelContext().getTypeConverter(); @@ -213,22 +215,24 @@ private void invokeTools( headerValue = value; } - exchange.getMessage().setHeader(name, headerValue); + toolExchange.getMessage().setHeader(name, headerValue); }); // Execute the consumer route - camelToolSpecification.getConsumer().getProcessor().process(exchange); + camelToolSpecification.getConsumer().getProcessor().process(toolExchange); i++; } catch (Exception e) { // How to handle this exception? - exchange.setException(e); + toolExchange.setException(e); } + ExchangeHelper.copyResults(exchange, toolExchange); + chatMessages.add(new ToolExecutionResultMessage( toolExecutionRequest.id(), toolExecutionRequest.name(), - exchange.getIn().getBody(String.class))); + toolExchange.getIn().getBody(String.class))); } // Clear route stop flag after all tools so it does not leak diff --git a/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolMultipleCallsTest.java b/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolMultipleCallsTest.java index 39a60358a9be0..9c277d3b8c641 100644 --- a/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolMultipleCallsTest.java +++ b/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolMultipleCallsTest.java @@ -47,7 +47,7 @@ public class LangChain4jToolMultipleCallsTest extends CamelTestSupport { .build(); private volatile boolean intermediateCalled = false; - private volatile boolean intermediateHasValidBody = false; + private volatile boolean intermediateIsolated = false; @Override protected void setupResources() throws Exception { @@ -82,10 +82,15 @@ public void configure() { .process(exchange -> { String body = exchange.getIn().getBody(String.class); intermediateCalled = true; - if (exchange.getIn().getHeader("longitude", String.class).contains("0") && - exchange.getIn().getHeader("latitude", String.class).contains("51") && - body.contains("51.50758961965397") && body.contains("-0.13388057363742217")) { - intermediateHasValidBody = true; + // CAMEL-23704: the forecast tool must see its own argument headers but not + // inherit the previous tool's 'name' header or output body (it receives the + // original incoming chat messages) + boolean ownHeaders = exchange.getIn().getHeader("longitude", String.class).contains("0") + && exchange.getIn().getHeader("latitude", String.class).contains("51"); + boolean noHeaderLeak = exchange.getIn().getHeader("name") == null; + boolean originalBody = body != null && body.contains("meteorologist"); + if (ownHeaders && noHeaderLeak && originalBody) { + intermediateIsolated = true; } }) .setBody(simple(""" @@ -135,6 +140,6 @@ public void testSimpleInvocation() { // depending on the reasoning model used to test, the result is different, but we asked for Celcius degree and 3 should be part of it Assertions.assertThat(responseContent).containsIgnoringCase("3"); Assertions.assertThat(intermediateCalled).isTrue(); - Assertions.assertThat(intermediateHasValidBody).isTrue(); + Assertions.assertThat(intermediateIsolated).isTrue(); } } diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc index 77d793d1cbc2d..cbba0ce981c01 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc @@ -34,6 +34,18 @@ endpoint URI, which could leave the data format incompletely configured. This al `marshal()` / `unmarshal()` DSL, which already honors the global configuration. Options specified on the endpoint URI continue to take precedence over the global configuration. +=== camel-langchain4j-tools + +When the LLM requests multiple tool invocations within a single request, each tool is now invoked on its +own independent copy of the exchange (similar to the multicast/splitter patterns) instead of sharing a single +exchange across all tools. This guarantees complete isolation: the message body, argument headers, and any +exception produced by one tool no longer leak into subsequent tool invocations of the same request. + +As a consequence, a tool route no longer receives the previous tool's output as its message body, nor the +previous tool's argument headers. Each tool route starts from the original incoming exchange and only sees +its own declared arguments as headers. Routes that (intentionally or accidentally) relied on this leaked state +between tools must be adjusted to carry such data explicitly. + === camel-core ==== Simple language: internal builder classes reorganized