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

oscerd 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 0bc55138a238 CAMEL-24832: camel-openai, camel-spring-ai-chat - 
propagate the caller's context into ai-tool route invocations (#27264)
0bc55138a238 is described below

commit 0bc55138a2382e9bd1b9daecce33ab028ffa3fa8
Author: Andrea Cosentino <[email protected]>
AuthorDate: Mon Oct 5 15:15:54 2026 +0200

    CAMEL-24832: camel-openai, camel-spring-ai-chat - propagate the caller's 
context into ai-tool route invocations (#27264)
    
    The camel-ai-tool contract (blog "Authorizing what an AI agent may do in 
Apache Camel", CAMEL-23944) is that the
    context the caller set before the agent ran - most importantly an 
authenticated caller's identity kept as an
    exchange property - reaches each tool route, so the route can be guarded on 
it (exchangeProperty.subject) and the
    model cannot forge it. That held only for camel-langchain4j-agent. 
camel-openai (McpToolCallExecutor) and
    camel-spring-ai-chat (AiToolSpecToSpringAi) created a fresh exchange 
instead, so a tool route guarded on
    exchangeProperty.subject saw null and denied every call, and any other 
context (correlation ids, tenant,
    variables) was lost. This bit the no-Java-bean YAML path 
(openai:chat-completion?tags=...) hardest.
    
    Add a shared helper AiToolExecutor.createToolExchange(callingExchange) in 
camel-ai-tool so all three runtimes
    build the tool exchange identically and cannot drift again. It copies the 
caller's exchange properties and
    variables, then gives the tool route a CLEAN message: the route receives 
only its own arguments (set as headers
    by execute()), not the caller's body or inbound headers, and a tool that 
sets no body returns "No result" rather
    than echoing the caller's body back to the model. The copy runs in its OWN 
unit of work and with its own exchange
    id (not the caller's), so the tool route's own onCompletion, error handler 
and useOriginalMessage() apply to the
    tool call, parallel tool calls get distinct ids, and an error handler 
cannot restore the caller's message into the
    result. Changes the tool makes are isolated to the copy and do not leak 
back into the calling exchange.
    
    - camel-openai: thread the calling exchange through execute -> executeOne 
-> executeRouteTool and build the tool
      exchange from it; remove the now-incorrect releaseExchange() (the copy is 
not a pooled consumer exchange).
    - camel-spring-ai-chat: thread it through getToolCallbacksForTags -> 
discoverAiRegistryTools -> toToolCallback,
      capture it in the tool callback, and remove releaseExchange() likewise.
    - camel-langchain4j-agent: replace its inline 
ExchangeHelper.createCopy(exchange, true) with the shared helper.
      This is a behaviour change for langchain4j route tools: they previously 
received a full copy of the caller's
      message (body + inbound headers) and ran in the caller's unit of work and 
exchange id; they now receive a clean
      message and run in their own unit of work and id. Documented in the 4.23 
upgrade guide.
    
    Documented the contract in the camel-ai-tool component doc ("The calling 
exchange"). Tests:
    
AiToolExecutorTest.createToolExchangeCopiesCallerContextButGivesACleanMessage 
(properties and variables carried;
    message clean; own exchange id; tool-side changes isolated),
    AiToolExecutorTest.createToolExchangeGivesTheToolRouteItsOwnUnitOfWork (an 
error handler's useOriginalMessage()
    restores the tool's own clean message, not the caller's) and
    
McpToolCallExecutorTest.routeToolSeesCallerExchangePropertyAndGetsACleanMessage 
(openai wiring - a tool route
    reads the caller's exchange property, and a no-body tool returns "No 
result").
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    Signed-off-by: Andrea Cosentino <[email protected]>
---
 .../camel/catalog/docs/ai-tool-component.adoc      | 30 ++++++--
 .../src/main/docs/ai-tool-component.adoc           | 30 ++++++--
 .../camel/component/ai/tool/AiToolExecutor.java    | 39 ++++++++++-
 .../component/ai/tool/AiToolExecutorTest.java      | 64 +++++++++++++++++
 .../agent/LangChain4jAgentProducer.java            |  9 ++-
 .../component/openai/McpToolCallExecutor.java      | 78 +++++++++++----------
 .../camel/component/openai/OpenAIProducer.java     |  2 +-
 .../component/openai/OpenAIResponsesProducer.java  |  2 +-
 .../openai/OpenAIToolExecutionProducer.java        |  2 +-
 .../component/openai/McpToolCallExecutorTest.java  | 80 +++++++++++++++++++---
 .../springai/chat/AiToolSpecToSpringAi.java        | 35 +++++-----
 .../springai/chat/SpringAiChatProducer.java        | 10 +--
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    | 18 +++++
 13 files changed, 310 insertions(+), 89 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
index 8d431a7d5210..ab10c665857f 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
@@ -353,6 +353,30 @@ YAML::
 ----
 ====
 
+== The calling exchange
+
+Each tool invocation runs on a copy of the *calling exchange* — the exchange 
that drives the agent (the
+`langchain4j-agent`, `openai` or `spring-ai-chat` producer). The context the 
caller set before the agent ran is
+carried onto the tool route:
+
+* exchange *properties* — most importantly an authenticated caller's identity 
kept as a property, so a tool route
+  can be guarded on it (for example `exchangeProperty.subject`) and the model 
cannot forge it;
+* exchange *variables*.
+
+The message itself is *clean*: the tool route receives only its own arguments 
(supplied by the model and placed on
+the message as headers), not the caller's body or inbound headers, and a tool 
that sets no body returns `No result`
+rather than echoing the caller's body back to the model. Any change the tool 
makes — to its message, body or
+exception — is isolated to the copy and does not leak back into the calling 
exchange.
+
+The tool exchange runs in its own *unit of work* and with its own exchange id; 
it does not share the caller's. So
+each tool call is independent: the tool route's own `onCompletion`, error 
handler and `useOriginalMessage()` apply
+to the tool call rather than to the caller, parallel tool calls in a batch get 
distinct exchange ids, and an error
+handler cannot restore the caller's message into the result.
+
+All route-tool runtimes build the tool exchange the same way, so an 
authorization check on an exchange property
+behaves identically whether the agent loop is driven by 
`camel-langchain4j-agent`, `camel-openai` or
+`camel-spring-ai-chat`.
+
 == Authorizing tool calls
 
 A tool call is a security boundary: an AI model decides, from its own output, 
which `ai-tool` route to invoke. Set
@@ -410,10 +434,8 @@ The policy authorizes on *trustworthy* input only:
 
 Which runtimes carry the caller identity:
 
-* `camel-langchain4j-agent` copies the calling exchange, so the identity 
property reaches the tool route today.
-* `camel-openai` and `camel-spring-ai-chat` currently build a fresh exchange, 
so an identity-based policy denies
-  under them until CAMEL-24832 propagates the caller context — the guard still 
applies, it simply has no identity to
-  authorize on yet.
+* `camel-langchain4j-agent`, `camel-openai` and `camel-spring-ai-chat` all 
copy the calling exchange (see
+  <<The calling exchange>> above), so the identity property reaches the tool 
route.
 * Over the xref:others:mcp-server.adoc[MCP server] the authenticated transport 
caller is carried onto the tool
   exchange as the `CamelMcpSecurityPrincipal` property (the raw transport 
principal — for the Vert.x streamable HTTP
   server, an `io.vertx.ext.auth.User`). A policy over MCP reads that property 
directly; Camel's shipped
diff --git 
a/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc 
b/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc
index 8d431a7d5210..ab10c665857f 100644
--- a/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc
+++ b/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc
@@ -353,6 +353,30 @@ YAML::
 ----
 ====
 
+== The calling exchange
+
+Each tool invocation runs on a copy of the *calling exchange* — the exchange 
that drives the agent (the
+`langchain4j-agent`, `openai` or `spring-ai-chat` producer). The context the 
caller set before the agent ran is
+carried onto the tool route:
+
+* exchange *properties* — most importantly an authenticated caller's identity 
kept as a property, so a tool route
+  can be guarded on it (for example `exchangeProperty.subject`) and the model 
cannot forge it;
+* exchange *variables*.
+
+The message itself is *clean*: the tool route receives only its own arguments 
(supplied by the model and placed on
+the message as headers), not the caller's body or inbound headers, and a tool 
that sets no body returns `No result`
+rather than echoing the caller's body back to the model. Any change the tool 
makes — to its message, body or
+exception — is isolated to the copy and does not leak back into the calling 
exchange.
+
+The tool exchange runs in its own *unit of work* and with its own exchange id; 
it does not share the caller's. So
+each tool call is independent: the tool route's own `onCompletion`, error 
handler and `useOriginalMessage()` apply
+to the tool call rather than to the caller, parallel tool calls in a batch get 
distinct exchange ids, and an error
+handler cannot restore the caller's message into the result.
+
+All route-tool runtimes build the tool exchange the same way, so an 
authorization check on an exchange property
+behaves identically whether the agent loop is driven by 
`camel-langchain4j-agent`, `camel-openai` or
+`camel-spring-ai-chat`.
+
 == Authorizing tool calls
 
 A tool call is a security boundary: an AI model decides, from its own output, 
which `ai-tool` route to invoke. Set
@@ -410,10 +434,8 @@ The policy authorizes on *trustworthy* input only:
 
 Which runtimes carry the caller identity:
 
-* `camel-langchain4j-agent` copies the calling exchange, so the identity 
property reaches the tool route today.
-* `camel-openai` and `camel-spring-ai-chat` currently build a fresh exchange, 
so an identity-based policy denies
-  under them until CAMEL-24832 propagates the caller context — the guard still 
applies, it simply has no identity to
-  authorize on yet.
+* `camel-langchain4j-agent`, `camel-openai` and `camel-spring-ai-chat` all 
copy the calling exchange (see
+  <<The calling exchange>> above), so the identity property reaches the tool 
route.
 * Over the xref:others:mcp-server.adoc[MCP server] the authenticated transport 
caller is carried onto the tool
   exchange as the `CamelMcpSecurityPrincipal` property (the raw transport 
principal — for the Vert.x streamable HTTP
   server, an `io.vertx.ext.auth.User`). A policy over MCP reads that property 
directly; Camel's shipped
diff --git 
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java
 
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java
index 85a485d22fca..4e5a2f405d19 100644
--- 
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java
+++ 
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java
@@ -61,8 +61,9 @@ public final class AiToolExecutor {
      * {@code camel} or {@code org.apache.camel.} (case-insensitive) are 
rejected to prevent collision with internal
      * Camel headers (following the same pattern as the A2A component).
      * <p>
-     * The calling adapter owns the exchange lifecycle: it must create the 
exchange before calling this method and
-     * release it afterwards (via {@code consumer.releaseExchange()}) in a 
try-finally block.
+     * The calling adapter obtains the exchange from {@link 
#createToolExchange(Exchange)} and passes it in. That copy
+     * is not a pooled consumer exchange, so the adapter does not release it 
afterwards (there is no
+     * {@code releaseExchange()} to call).
      * <p>
      * All errors — validation failures and route execution errors — are 
caught and returned as typed
      * {@link AiToolResult} variants rather than propagated. Framework 
adapters inspect the result type and decide how
@@ -211,4 +212,38 @@ public final class AiToolExecutor {
                     String.format("Error executing tool '%s': %s", 
spec.getName(), e.getMessage()), e);
         }
     }
+
+    /**
+     * Builds the exchange used to invoke a route tool from the calling 
(agent) exchange. The caller's <em>context</em>
+     * is carried over - exchange properties (most importantly the 
authenticated caller's identity, so a tool route can
+     * be guarded on {@code exchangeProperty.subject} and the model cannot 
forge it) and variables - but the tool route
+     * is given a <em>clean message</em>: it receives only its own tool 
arguments (set as headers by
+     * {@link #execute(AiToolSpec, Map, Exchange)}), not the caller's body or 
inbound headers, and a tool that sets no
+     * body returns {@code No result} rather than echoing the caller's body 
back to the model.
+     * <p>
+     * The tool exchange runs in its <em>own</em> unit of work and with its 
own exchange id - it does not share the
+     * caller's. This keeps each tool call independent: the tool route's own 
{@code onCompletion}, error handler and
+     * {@code useOriginalMessage()} apply to the tool call (not to the 
caller), parallel tool calls in one batch get
+     * distinct ids, and an error handler cannot restore the caller's message 
into the result. Changes the tool makes
+     * are isolated to this copy and do not leak back into the calling 
exchange. Every route-tool runtime
+     * (langchain4j-agent, openai, spring-ai-chat) builds the tool exchange 
this way, so an authorization check on an
+     * exchange property behaves identically across them (CAMEL-24832, 
CAMEL-23944).
+     *
+     * @param  callingExchange the exchange driving the agent
+     * @return                 an isolated copy with its own unit of work and 
id, carrying the caller's properties and
+     *                         variables but a clean message, to pass to 
{@link #execute(AiToolSpec, Map, Exchange)}
+     */
+    public static Exchange createToolExchange(Exchange callingExchange) {
+        // copy() carries the caller's context (properties and variables). The 
tool route must then run in its OWN unit
+        // of work and with its own exchange id, NOT the caller's: sharing the 
caller's UnitOfWork would stop the tool
+        // route's onCompletion from firing, give every parallel tool call the 
caller's exchange id, and -- through an
+        // error handler's useOriginalMessage() -- restore the caller's body 
and headers into the result, undoing the
+        // clean message. So detach the unit of work, then wipe the message so 
the tool route starts from its own
+        // arguments only.
+        Exchange toolExchange = callingExchange.copy();
+        toolExchange.getExchangeExtension().setUnitOfWork(null);
+        toolExchange.getMessage().setBody(null);
+        toolExchange.getMessage().getHeaders().clear();
+        return toolExchange;
+    }
 }
diff --git 
a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolExecutorTest.java
 
b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolExecutorTest.java
index 34c71452100f..2985db156dbe 100644
--- 
a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolExecutorTest.java
+++ 
b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolExecutorTest.java
@@ -22,6 +22,7 @@ import java.util.Map;
 
 import org.apache.camel.Exchange;
 import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
 import org.apache.camel.support.DefaultConsumer;
 import org.apache.camel.support.DefaultExchange;
 import org.apache.camel.test.junit6.CamelTestSupport;
@@ -70,6 +71,12 @@ public class AiToolExecutorTest extends CamelTestSupport {
                      + "&description=A tool that always fails")
                         .throwException(new RuntimeException("Simulated 
failure"));
 
+                from("ai-tool:dlqTool"
+                     + "?tags=test"
+                     + "&description=A tool whose error handler restores the 
original message")
+                        
.errorHandler(deadLetterChannel("mock:dlq").useOriginalMessage())
+                        .throwException(new RuntimeException("boom"));
+
                 from("ai-tool:exchangeExceptionTool"
                      + "?tags=test"
                      + "&description=A tool that sets exception on exchange")
@@ -457,6 +464,63 @@ public class AiToolExecutorTest extends CamelTestSupport {
         assertThat(((AiToolResult.ExecutionError) 
result).message()).contains("must not be null");
     }
 
+    @Test
+    void createToolExchangeCopiesCallerContextButGivesACleanMessage() {
+        // CAMEL-24832: the tool exchange carries the caller's context - an 
authenticated subject kept as an exchange
+        // property, and variables - so a tool route can be guarded on it; but 
the message is clean, so the route gets
+        // only its own arguments, not the caller's body or inbound headers, 
and changes do not leak back.
+        Exchange calling = new DefaultExchange(context);
+        calling.setProperty("CamelAuthenticatedSubject", "alice");
+        calling.setVariable("tenant", "acme");
+        calling.getIn().setHeader("origHeader", "h1");
+        calling.getIn().setBody("original-body");
+
+        Exchange toolExchange = AiToolExecutor.createToolExchange(calling);
+
+        // the caller's context reaches the tool route
+        
assertThat(toolExchange.getProperty("CamelAuthenticatedSubject")).isEqualTo("alice");
+        assertThat(toolExchange.getVariable("tenant")).isEqualTo("acme");
+
+        // the tool exchange has its own id, not the caller's
+        
assertThat(toolExchange.getExchangeId()).isNotEqualTo(calling.getExchangeId());
+
+        // but the message is clean: no caller body, no caller inbound headers
+        assertThat(toolExchange.getMessage().getBody()).isNull();
+        assertThat(toolExchange.getMessage().getHeader("origHeader")).isNull();
+
+        // and changes on the tool exchange do not leak back into the caller
+        toolExchange.setProperty("CamelAuthenticatedSubject", "mallory");
+        toolExchange.getMessage().setBody("tool-body");
+        
assertThat(calling.getProperty("CamelAuthenticatedSubject")).isEqualTo("alice");
+        assertThat(calling.getIn().getBody()).isEqualTo("original-body");
+    }
+
+    @Test
+    void createToolExchangeGivesTheToolRouteItsOwnUnitOfWork() throws 
Exception {
+        // CAMEL-24832: the tool exchange runs in its OWN unit of work, not 
the caller's. Otherwise an error handler's
+        // useOriginalMessage() would restore the caller's body (the user 
prompt) into the tool result, undoing the
+        // clean message, and the tool route's own completion/original-message 
handling would not apply.
+        AiToolSpec spec = findSpec("dlqTool");
+
+        Exchange calling = new DefaultExchange(context);
+        calling.getIn().setBody("caller-prompt");
+        calling.getIn().setHeader("origHeader", "h1");
+
+        MockEndpoint dlq = getMockEndpoint("mock:dlq");
+        dlq.expectedMessageCount(1);
+
+        AiToolResult result = AiToolExecutor.execute(spec, Map.of(), 
AiToolExecutor.createToolExchange(calling));
+
+        dlq.assertIsSatisfied();
+        // the dead-letter message is the tool exchange's own (clean) 
original, NOT the caller's prompt or headers
+        Exchange dead = dlq.getExchanges().get(0);
+        assertThat(dead.getMessage().getBody()).isNull();
+        assertThat(dead.getMessage().getHeader("origHeader")).isNull();
+        // the error handler handled the exception, so the tool returns its 
own clean body, not the caller's prompt
+        assertThat(result).isInstanceOf(AiToolResult.Success.class);
+        assertThat(((AiToolResult.Success) result).value()).isEqualTo("No 
result");
+    }
+
     private AiToolSpec findSpec(String toolName) {
         return 
AiToolRegistry.getOrCreate(context).getToolsByTag("test").stream()
                 .filter(s -> toolName.equals(s.getName()))
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 87b135a1bfb8..b850b9f3f195 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
@@ -72,7 +72,6 @@ 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;
 import org.apache.camel.util.ObjectHelper;
 import org.slf4j.Logger;
@@ -447,10 +446,10 @@ public class LangChain4jAgentProducer extends 
DefaultProducer {
             if (arguments == null) {
                 return "Invalid arguments: could not parse the provided JSON 
arguments";
             }
-            // 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);
+            // Isolate each tool invocation in its own exchange copy so that 
headers, body mutations and exceptions do
+            // not leak into the calling producer exchange, while the caller's 
context (properties/variables) reaches
+            // the tool route. Shared across the route-tool runtimes so they 
cannot drift (CAMEL-24832, CAMEL-23944).
+            Exchange toolExchange = 
AiToolExecutor.createToolExchange(exchange);
             AiToolResult result = AiToolExecutor.execute(spec, arguments, 
toolExchange);
             return toToolResponse(spec.getName(), result);
         };
diff --git 
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/McpToolCallExecutor.java
 
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/McpToolCallExecutor.java
index 40fbf92d2e24..2325d1b4b42d 100644
--- 
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/McpToolCallExecutor.java
+++ 
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/McpToolCallExecutor.java
@@ -105,11 +105,13 @@ class McpToolCallExecutor extends ServiceSupport {
     /**
      * Executes every tool call in the batch and returns the results in the 
original order.
      *
-     * @param  toolCalls the tool calls requested by the model
-     * @return           one result per tool call, in the same order
-     * @throws Exception when a tool call fails and the configured strategy is 
to fail the exchange
+     * @param  toolCalls       the tool calls requested by the model
+     * @param  callingExchange the exchange driving the agent loop; copied 
into each route-tool invocation so the
+     *                         caller's context (properties, variables) 
reaches the tool route
+     * @return                 one result per tool call, in the same order
+     * @throws Exception       when a tool call fails and the configured 
strategy is to fail the exchange
      */
-    List<ToolResult> execute(List<ChatCompletionMessageToolCall> toolCalls) 
throws Exception {
+    List<ToolResult> execute(List<ChatCompletionMessageToolCall> toolCalls, 
Exchange callingExchange) throws Exception {
         if (toolCalls.isEmpty()) {
             return List.of();
         }
@@ -122,15 +124,16 @@ class McpToolCallExecutor extends ServiceSupport {
         if (executorService == null || toolCalls.size() == 1) {
             List<ToolResult> results = new ArrayList<>(toolCalls.size());
             for (ChatCompletionMessageToolCall toolCall : toolCalls) {
-                results.add(executeOne(toolCall, toolState));
+                results.add(executeOne(toolCall, toolState, callingExchange));
             }
             return results;
         }
 
-        return executeParallel(toolCalls, toolState);
+        return executeParallel(toolCalls, toolState, callingExchange);
     }
 
-    private List<ToolResult> 
executeParallel(List<ChatCompletionMessageToolCall> toolCalls, McpToolState 
toolState)
+    private List<ToolResult> executeParallel(
+            List<ChatCompletionMessageToolCall> toolCalls, McpToolState 
toolState, Exchange callingExchange)
             throws Exception {
         LOG.debug("Executing {} tool call(s) in parallel", toolCalls.size());
 
@@ -139,7 +142,7 @@ class McpToolCallExecutor extends ServiceSupport {
 
         List<Future<ToolResult>> futures = new ArrayList<>(toolCalls.size());
         for (ChatCompletionMessageToolCall toolCall : toolCalls) {
-            futures.add(executorService.submit(withMdc(mdc, () -> 
executeOne(toolCall, toolState))));
+            futures.add(executorService.submit(withMdc(mdc, () -> 
executeOne(toolCall, toolState, callingExchange))));
         }
 
         long timeout = endpoint.getConfiguration().getParallelToolTimeout();
@@ -211,7 +214,8 @@ class McpToolCallExecutor extends ServiceSupport {
         };
     }
 
-    private ToolResult executeOne(ChatCompletionMessageToolCall toolCall, 
McpToolState toolState) throws Exception {
+    private ToolResult executeOne(ChatCompletionMessageToolCall toolCall, 
McpToolState toolState, Exchange callingExchange)
+            throws Exception {
         long startNanos = System.nanoTime();
         OpenAIConfiguration config = endpoint.getConfiguration();
         String toolName = toolCall.asFunction().function().name();
@@ -219,7 +223,7 @@ class McpToolCallExecutor extends ServiceSupport {
 
         AiToolSpec routeSpec = toolState.routeTools().get(toolName);
         if (routeSpec != null) {
-            return timed(startNanos, executeRouteTool(toolCall, routeSpec, 
toolState, config));
+            return timed(startNanos, executeRouteTool(toolCall, routeSpec, 
toolState, config, callingExchange));
         }
 
         McpSyncClient mcpClient = toolState.toolClientMap().get(toolName);
@@ -270,7 +274,8 @@ class McpToolCallExecutor extends ServiceSupport {
             ChatCompletionMessageToolCall toolCall,
             AiToolSpec spec,
             McpToolState toolState,
-            OpenAIConfiguration config)
+            OpenAIConfiguration config,
+            Exchange callingExchange)
             throws Exception {
         String toolName = toolCall.asFunction().function().name();
         String argsJson = toolCall.asFunction().function().arguments();
@@ -279,34 +284,33 @@ class McpToolCallExecutor extends ServiceSupport {
 
         try {
             Map<String, Object> argsMap = OBJECT_MAPPER.readValue(argsJson, 
Map.class);
-            Exchange toolExchange = spec.getConsumer().createExchange(false);
-            try {
-                AiToolResult result = AiToolExecutor.execute(spec, argsMap, 
toolExchange);
-                if (result instanceof AiToolResult.Success success) {
-                    LOG.debug("Route tool '{}' result: {}", toolName, 
success.value());
-                    return new ToolResult(
-                            toolCall.asFunction().id(), toolName, 
success.value(),
-                            toolState.returnDirectTools().contains(toolName), 
0, true);
-                } else if (result instanceof AiToolResult.ArgumentError error) 
{
-                    LOG.warn("Route tool '{}' argument error: {}", toolName, 
error.message());
-                    return errorResult(toolCall, "Error: invalid tool 
arguments: " + error.message());
-                } else if (result instanceof AiToolResult.AuthorizationDenied 
denied) {
-                    // A denial is expected control flow: always relay the 
refusal to the model, never fail the exchange.
-                    LOG.warn("Route tool '{}' call denied by authorization 
policy", toolName);
-                    return errorResult(toolCall, denied.message());
-                } else {
-                    AiToolResult.ExecutionError error = 
(AiToolResult.ExecutionError) result;
-                    if (config.getToolExecutionErrorStrategy() == 
ToolExecutionErrorStrategy.FAIL_EXCHANGE) {
-                        if (error.cause() != null) {
-                            throw error.cause();
-                        }
-                        throw new IllegalStateException(error.message());
+            // isolated copy of the calling exchange so the caller's context 
(e.g. an authenticated subject kept as an
+            // exchange property) reaches the tool route; this is not a pooled 
consumer exchange, so it is not released
+            // here (CAMEL-24832)
+            Exchange toolExchange = 
AiToolExecutor.createToolExchange(callingExchange);
+            AiToolResult result = AiToolExecutor.execute(spec, argsMap, 
toolExchange);
+            if (result instanceof AiToolResult.Success success) {
+                LOG.debug("Route tool '{}' result: {}", toolName, 
success.value());
+                return new ToolResult(
+                        toolCall.asFunction().id(), toolName, success.value(),
+                        toolState.returnDirectTools().contains(toolName), 0, 
true);
+            } else if (result instanceof AiToolResult.ArgumentError error) {
+                LOG.warn("Route tool '{}' argument error: {}", toolName, 
error.message());
+                return errorResult(toolCall, "Error: invalid tool arguments: " 
+ error.message());
+            } else if (result instanceof AiToolResult.AuthorizationDenied 
denied) {
+                // A denial is expected control flow: always relay the refusal 
to the model, never fail the exchange.
+                LOG.warn("Route tool '{}' call denied by authorization 
policy", toolName);
+                return errorResult(toolCall, denied.message());
+            } else {
+                AiToolResult.ExecutionError error = 
(AiToolResult.ExecutionError) result;
+                if (config.getToolExecutionErrorStrategy() == 
ToolExecutionErrorStrategy.FAIL_EXCHANGE) {
+                    if (error.cause() != null) {
+                        throw error.cause();
                     }
-                    LOG.warn("Route tool '{}' execution failed: {}", toolName, 
error.message(), error.cause());
-                    return errorResult(toolCall, "Error: Tool execution 
failed: " + error.message());
+                    throw new IllegalStateException(error.message());
                 }
-            } finally {
-                spec.getConsumer().releaseExchange(toolExchange, false);
+                LOG.warn("Route tool '{}' execution failed: {}", toolName, 
error.message(), error.cause());
+                return errorResult(toolCall, "Error: Tool execution failed: " 
+ error.message());
             }
         } catch (JsonProcessingException e) {
             if (config.getToolExecutionErrorStrategy() == 
ToolExecutionErrorStrategy.FAIL_EXCHANGE) {
diff --git 
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
 
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
index 3c0851290729..c6c50b36a0bf 100644
--- 
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
+++ 
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
@@ -443,7 +443,7 @@ public class OpenAIProducer extends DefaultAsyncProducer {
                 }
 
                 // Execute all tool calls in this batch
-                List<McpToolCallExecutor.ToolResult> batchResults = 
toolCallExecutor.execute(toolCalls);
+                List<McpToolCallExecutor.ToolResult> batchResults = 
toolCallExecutor.execute(toolCalls, exchange);
                 observability.recordIteration(
                         modelCall, iterationStartNanos, iterationPromptTokens, 
iterationCompletionTokens, toolCalls,
                         batchResults);
diff --git 
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIResponsesProducer.java
 
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIResponsesProducer.java
index c5739308c114..4af84bc894ec 100644
--- 
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIResponsesProducer.java
+++ 
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIResponsesProducer.java
@@ -273,7 +273,7 @@ public class OpenAIResponsesProducer extends 
DefaultAsyncProducer {
 
                 functionCalls.forEach(call -> toolCallsLog.add(call.name()));
                 List<ChatCompletionMessageToolCall> toolCalls = 
OpenAIResponsesSupport.toChatToolCalls(functionCalls);
-                List<McpToolCallExecutor.ToolResult> results = 
toolCallExecutor.execute(toolCalls);
+                List<McpToolCallExecutor.ToolResult> results = 
toolCallExecutor.execute(toolCalls, exchange);
                 observability.recordIteration(
                         modelCall, iterationStartNanos, iterationPromptTokens, 
iterationCompletionTokens, toolCalls,
                         results);
diff --git 
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIToolExecutionProducer.java
 
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIToolExecutionProducer.java
index 318d5a437197..cdb3260e9daa 100644
--- 
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIToolExecutionProducer.java
+++ 
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIToolExecutionProducer.java
@@ -149,7 +149,7 @@ public class OpenAIToolExecutionProducer extends 
DefaultProducer {
                     "No tools configured on the endpoint. Configure 
mcpServer.* parameters and/or the tags option.");
         }
 
-        List<McpToolCallExecutor.ToolResult> results = 
toolCallExecutor.execute(toolCalls);
+        List<McpToolCallExecutor.ToolResult> results = 
toolCallExecutor.execute(toolCalls, exchange);
         for (McpToolCallExecutor.ToolResult result : results) {
             history.add(ChatCompletionMessageParam.ofTool(
                     ChatCompletionToolMessageParam.builder()
diff --git 
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/McpToolCallExecutorTest.java
 
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/McpToolCallExecutorTest.java
index 44e09d234f23..f30e54b97658 100644
--- 
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/McpToolCallExecutorTest.java
+++ 
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/McpToolCallExecutorTest.java
@@ -23,12 +23,18 @@ import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 
 import 
com.openai.models.chat.completions.ChatCompletionMessageFunctionToolCall;
 import com.openai.models.chat.completions.ChatCompletionMessageToolCall;
 import io.modelcontextprotocol.client.McpSyncClient;
 import io.modelcontextprotocol.spec.McpSchema;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.ai.tool.AiToolRegistry;
+import org.apache.camel.component.ai.tool.AiToolSpec;
 import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -64,6 +70,12 @@ class McpToolCallExecutorTest {
         context.stop();
     }
 
+    // CAMEL-24832: the executor now takes the calling exchange (copied into 
each route-tool invocation). These tests
+    // exercise MCP tool calls, which do not read it, so a throwaway exchange 
is enough to drive the batch.
+    private List<McpToolCallExecutor.ToolResult> 
execute(List<ChatCompletionMessageToolCall> toolCalls) throws Exception {
+        return executor.execute(toolCalls, new DefaultExchange(context));
+    }
+
     // ------------------------------------------------------------------
     // Ordering
     // ------------------------------------------------------------------
@@ -82,7 +94,7 @@ class McpToolCallExecutorTest {
         executor = startExecutor(endpoint);
 
         List<McpToolCallExecutor.ToolResult> results
-                = executor.execute(List.of(toolCall("id-a", "slow_a"), 
toolCall("id-b", "slow_b"),
+                = execute(List.of(toolCall("id-a", "slow_a"), toolCall("id-b", 
"slow_b"),
                         toolCall("id-c", "slow_c")));
 
         
assertThat(results).extracting(McpToolCallExecutor.ToolResult::toolCallId)
@@ -100,7 +112,7 @@ class McpToolCallExecutorTest {
         executor = startExecutor(endpoint);
 
         List<McpToolCallExecutor.ToolResult> results
-                = executor.execute(List.of(toolCall("id-a", "tool_a"), 
toolCall("id-b", "tool_b")));
+                = execute(List.of(toolCall("id-a", "tool_a"), toolCall("id-b", 
"tool_b")));
 
         
assertThat(results).extracting(McpToolCallExecutor.ToolResult::toolCallId)
                 .containsExactly("id-a", "id-b");
@@ -112,7 +124,7 @@ class McpToolCallExecutorTest {
         OpenAIEndpoint endpoint = newEndpoint(true, 0, Map.of("tool_a", 
staticClient("A")), Set.of());
         executor = startExecutor(endpoint);
 
-        assertThat(executor.execute(List.of())).isEmpty();
+        assertThat(execute(List.of())).isEmpty();
     }
 
     // ------------------------------------------------------------------
@@ -128,7 +140,7 @@ class McpToolCallExecutorTest {
         executor = startExecutor(endpoint);
 
         List<McpToolCallExecutor.ToolResult> results
-                = executor.execute(List.of(toolCall("id-a", "direct_tool"), 
toolCall("id-b", "normal_tool")));
+                = execute(List.of(toolCall("id-a", "direct_tool"), 
toolCall("id-b", "normal_tool")));
 
         
assertThat(results).extracting(McpToolCallExecutor.ToolResult::returnDirect).containsExactly(true,
 false);
     }
@@ -146,7 +158,7 @@ class McpToolCallExecutorTest {
                 = newEndpoint(true, 0, Map.of("direct_tool", failing), 
Set.of("direct_tool"));
         executor = startExecutor(endpoint);
 
-        List<McpToolCallExecutor.ToolResult> results = 
executor.execute(List.of(toolCall("id-a", "direct_tool")));
+        List<McpToolCallExecutor.ToolResult> results = 
execute(List.of(toolCall("id-a", "direct_tool")));
 
         assertThat(results).singleElement()
                 .satisfies(r -> {
@@ -180,7 +192,7 @@ class McpToolCallExecutorTest {
         executor = startExecutor(endpoint);
 
         assertThatThrownBy(
-                () -> executor.execute(List.of(toolCall("id-a", 
"failing_tool"), toolCall("id-b", "sibling_tool"))))
+                () -> execute(List.of(toolCall("id-a", "failing_tool"), 
toolCall("id-b", "sibling_tool"))))
                 .isInstanceOf(IllegalStateException.class)
                 .hasMessage("tool blew up");
 
@@ -201,7 +213,7 @@ class McpToolCallExecutorTest {
         executor = startExecutor(endpoint);
 
         List<McpToolCallExecutor.ToolResult> results
-                = executor.execute(List.of(toolCall("id-a", "failing_tool"), 
toolCall("id-b", "ok_tool")));
+                = execute(List.of(toolCall("id-a", "failing_tool"), 
toolCall("id-b", "ok_tool")));
 
         assertThat(results).extracting(McpToolCallExecutor.ToolResult::content)
                 .containsExactly("Error: Tool execution failed: tool blew up", 
"fine");
@@ -213,7 +225,7 @@ class McpToolCallExecutorTest {
         executor = startExecutor(endpoint);
 
         assertThatThrownBy(
-                () -> executor.execute(List.of(toolCall("id-a", "known_tool"), 
toolCall("id-b", "made_up_tool"))))
+                () -> execute(List.of(toolCall("id-a", "known_tool"), 
toolCall("id-b", "made_up_tool"))))
                 .isInstanceOf(IllegalStateException.class)
                 .hasMessageContaining("made_up_tool");
     }
@@ -225,7 +237,7 @@ class McpToolCallExecutorTest {
         executor = startExecutor(endpoint);
 
         List<McpToolCallExecutor.ToolResult> results
-                = executor.execute(List.of(toolCall("id-a", "made_up_tool"), 
toolCall("id-b", "known_tool")));
+                = execute(List.of(toolCall("id-a", "made_up_tool"), 
toolCall("id-b", "known_tool")));
 
         assertThat(results.get(0).content()).contains("made_up_tool", 
"known_tool");
         assertThat(results.get(0).returnDirect()).isFalse();
@@ -254,7 +266,7 @@ class McpToolCallExecutorTest {
             executor = startExecutor(endpoint);
 
             assertThatThrownBy(
-                    () -> executor.execute(List.of(toolCall("id-a", 
"blocking_tool"), toolCall("id-b", "fast_tool"))))
+                    () -> execute(List.of(toolCall("id-a", "blocking_tool"), 
toolCall("id-b", "fast_tool"))))
                     .isInstanceOf(TimeoutException.class)
                     .hasMessageContaining("blocking_tool")
                     .hasMessageContaining("parallelToolTimeout");
@@ -278,7 +290,7 @@ class McpToolCallExecutorTest {
 
             // two calls, so the batch is dispatched in parallel rather than 
run inline
             List<McpToolCallExecutor.ToolResult> results
-                    = executor.execute(List.of(toolCall("id-a", 
"blocking_tool"), toolCall("id-b", "blocking_tool")));
+                    = execute(List.of(toolCall("id-a", "blocking_tool"), 
toolCall("id-b", "blocking_tool")));
 
             
assertThat(results).extracting(McpToolCallExecutor.ToolResult::content)
                     .allSatisfy(content -> assertThat(content).contains("timed 
out after 200 ms"));
@@ -287,6 +299,52 @@ class McpToolCallExecutorTest {
         }
     }
 
+    // ------------------------------------------------------------------
+    // Route tools (CAMEL-24832)
+    // ------------------------------------------------------------------
+
+    @Test
+    void routeToolSeesCallerExchangePropertyAndGetsACleanMessage() throws 
Exception {
+        // CAMEL-24832: the openai route-tool path copies the CALLING 
exchange, so a tool route can read the caller's
+        // authenticated subject (kept as an exchange property) and the model 
cannot forge it; and the tool route gets a
+        // clean message, so a tool that sets no body returns "No result" 
rather than the caller's prompt.
+        AtomicReference<Object> seenSubject = new AtomicReference<>();
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("ai-tool:checkSubject?tags=test&description=Check the 
caller subject")
+                        .process(e -> 
seenSubject.set(e.getProperty("CamelAuthenticatedSubject")));
+            }
+        });
+
+        AiToolSpec spec = 
AiToolRegistry.getOrCreate(context).getToolsByTag("test").stream()
+                .filter(s -> "checkSubject".equals(s.getName()))
+                .findFirst()
+                .orElseThrow(() -> new AssertionError("route tool 
'checkSubject' not registered"));
+
+        OpenAIConfiguration configuration = new OpenAIConfiguration();
+        OpenAIComponent component = new OpenAIComponent();
+        component.setCamelContext(context);
+        OpenAIEndpoint endpoint = new OpenAIEndpoint("openai:chat-completion", 
component, configuration);
+        endpoint.setCamelContext(context);
+        endpoint.setMcpToolState(new McpToolState(
+                List.of(), Map.of(), Map.of(), Set.of(), 
Map.of("checkSubject", spec)));
+        executor = startExecutor(endpoint);
+
+        Exchange calling = new DefaultExchange(context);
+        calling.setProperty("CamelAuthenticatedSubject", "alice");
+        calling.getIn().setBody("the user prompt");
+
+        List<McpToolCallExecutor.ToolResult> results
+                = executor.execute(List.of(toolCall("id-x", "checkSubject")), 
calling);
+
+        // the tool route saw the caller's authenticated subject: the CALLING 
exchange was copied in, not a fresh one
+        assertThat(seenSubject).hasValue("alice");
+        // and the message was clean: a tool that sets no body returns "No 
result", not the caller's prompt
+        assertThat(results).singleElement()
+                .satisfies(r -> assertThat(r.content()).isEqualTo("No 
result"));
+    }
+
     // ------------------------------------------------------------------
     // Helpers
     // ------------------------------------------------------------------
diff --git 
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/AiToolSpecToSpringAi.java
 
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/AiToolSpecToSpringAi.java
index 713dc5f38dee..90a221d9ebc4 100644
--- 
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/AiToolSpecToSpringAi.java
+++ 
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/AiToolSpecToSpringAi.java
@@ -40,27 +40,26 @@ final class AiToolSpecToSpringAi {
     private AiToolSpecToSpringAi() {
     }
 
-    static ToolCallback toToolCallback(AiToolSpec spec) {
+    static ToolCallback toToolCallback(AiToolSpec spec, Exchange 
callingExchange) {
         Function<Map<String, Object>, String> function = args -> {
-            Exchange toolExchange = 
spec.getConsumer().getEndpoint().createExchange();
-            try {
-                AiToolResult result = AiToolExecutor.execute(spec, args, 
toolExchange);
-                if (result instanceof AiToolResult.Success success) {
-                    return success.value();
-                } else if (result instanceof AiToolResult.ArgumentError 
argErr) {
-                    return "Tool execution failed: " + argErr.message();
-                } else if (result instanceof AiToolResult.AuthorizationDenied 
denied) {
-                    // A denial is expected control flow: relay the refusal to 
the model.
-                    LOG.warn("Tool '{}' call denied by authorization policy", 
spec.getName());
-                    return denied.message();
-                } else if (result instanceof AiToolResult.ExecutionError 
execErr) {
-                    LOG.warn("Tool '{}' execution failed: {}", spec.getName(), 
execErr.message(), execErr.cause());
-                    return "Tool execution failed";
-                }
+            // isolated copy of the calling exchange so the caller's context 
(e.g. an authenticated subject kept as an
+            // exchange property) reaches the tool route; this is not a pooled 
consumer exchange, so it is not released
+            // here (CAMEL-24832)
+            Exchange toolExchange = 
AiToolExecutor.createToolExchange(callingExchange);
+            AiToolResult result = AiToolExecutor.execute(spec, args, 
toolExchange);
+            if (result instanceof AiToolResult.Success success) {
+                return success.value();
+            } else if (result instanceof AiToolResult.ArgumentError argErr) {
+                return "Tool execution failed: " + argErr.message();
+            } else if (result instanceof AiToolResult.AuthorizationDenied 
denied) {
+                // A denial is expected control flow: relay the refusal to the 
model.
+                LOG.warn("Tool '{}' call denied by authorization policy", 
spec.getName());
+                return denied.message();
+            } else if (result instanceof AiToolResult.ExecutionError execErr) {
+                LOG.warn("Tool '{}' execution failed: {}", spec.getName(), 
execErr.message(), execErr.cause());
                 return "Tool execution failed";
-            } finally {
-                spec.getConsumer().releaseExchange(toolExchange, false);
             }
+            return "Tool execution failed";
         };
 
         FunctionToolCallback.Builder builder = FunctionToolCallback
diff --git 
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatProducer.java
 
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatProducer.java
index 2ffb1d7e2f32..af6e1024fbaf 100644
--- 
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatProducer.java
+++ 
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatProducer.java
@@ -569,7 +569,7 @@ public class SpringAiChatProducer extends DefaultProducer {
         // Note: Augmented data is handled in the calling method for better 
control
 
         // Get tool callbacks first if tags are configured
-        List<ToolCallback> toolCallbacks = 
getToolCallbacksForTags(getEndpoint().getConfiguration().getTags());
+        List<ToolCallback> toolCallbacks = 
getToolCallbacksForTags(getEndpoint().getConfiguration().getTags(), exchange);
 
         // Apply chat options from configuration and headers using 
ToolCallingChatOptions
         // This ensures tool callbacks are properly included in the options
@@ -1002,14 +1002,14 @@ public class SpringAiChatProducer extends 
DefaultProducer {
      * Tools are registered via ChatOptions.toolCallbacks() which is the 
correct way to pass ToolCallback instances in
      * Spring AI. The tools() method expects objects with @Tool annotated 
methods, not ToolCallback instances.
      */
-    private List<ToolCallback> getToolCallbacksForTags(String tags) {
+    private List<ToolCallback> getToolCallbacksForTags(String tags, Exchange 
callingExchange) {
         if (tags == null || tags.trim().isEmpty()) {
             LOG.debug("No tags configured, skipping tool discovery");
             return List.of();
         }
 
         // Discover tools from the unified AiToolRegistry
-        List<ToolCallback> toolCallbacks = discoverAiRegistryTools(tags);
+        List<ToolCallback> toolCallbacks = discoverAiRegistryTools(tags, 
callingExchange);
 
         if (!toolCallbacks.isEmpty()) {
             // Collect tool names for enhanced logging
@@ -1079,7 +1079,7 @@ public class SpringAiChatProducer extends DefaultProducer 
{
      * Discover tools registered via {@code ai-tool:} consumer endpoints in 
the shared {@link AiToolRegistry}. Converts
      * each {@link AiToolSpec} to a Spring AI {@link ToolCallback} via {@link 
AiToolSpecToSpringAi}.
      */
-    private List<ToolCallback> discoverAiRegistryTools(String tags) {
+    private List<ToolCallback> discoverAiRegistryTools(String tags, Exchange 
callingExchange) {
         final AiToolRegistry registry = 
AiToolRegistry.getOrCreate(getEndpoint().getCamelContext());
         final String[] tagArray = AiToolParameterHelper.splitTags(tags);
 
@@ -1088,7 +1088,7 @@ public class SpringAiChatProducer extends DefaultProducer 
{
             uniqueSpecs.addAll(registry.getToolsByTag(tag));
         }
         final List<ToolCallback> toolCallbacks = uniqueSpecs.stream()
-                .map(AiToolSpecToSpringAi::toToolCallback)
+                .map(spec -> AiToolSpecToSpringAi.toToolCallback(spec, 
callingExchange))
                 .collect(Collectors.toList());
 
         LOG.debug("Discovered {} tools from AiToolRegistry for tags: {}", 
toolCallbacks.size(), tags);
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index df61b679b487..85f7d6333c9f 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -4462,6 +4462,24 @@ Property placeholders are kept as written instead of 
being resolved.
 In return, Java is also a target format, and rests, route templates, route 
configurations and beans are converted
 instead of only routes. A new `notes` field of the result lists what differs 
or is not carried over.
 
+=== camel-ai-tool - route tools receive the caller's context but a clean 
message
+
+Route tools invoked by an agent (`camel-langchain4j-agent`, `camel-openai`, 
`camel-spring-ai-chat`) now run on an
+exchange that carries the calling exchange's *properties* and *variables* — so 
a tool route can be guarded on the
+caller's identity, for example `exchangeProperty.subject` — but with a *clean 
message*: the tool route receives only
+its own arguments (as headers), not the caller's body or inbound headers.
+
+`camel-langchain4j-agent` previously copied the whole calling exchange, so its 
tool routes also saw the caller's body
+and inbound headers, and a tool that set no body returned the caller's body to 
the model. A tool route that read the
+caller's body or a caller header must now obtain that data another way (for 
example an exchange variable or property).
+The tool exchange also now runs in its own unit of work and with its own 
exchange id instead of the caller's, so a
+tool route's `onCompletion` and error handler apply to the tool call, parallel 
tool calls get distinct exchange ids,
+and an error handler's `useOriginalMessage()` no longer restores the caller's 
message into the result.
+
+`camel-openai` and `camel-spring-ai-chat` previously created a fresh exchange 
and did not propagate the caller's
+context at all; they now do, which is what makes a tool route guarded on 
`exchangeProperty.subject` work under those
+runtimes.
+
 === camel-ai-tool - new AiToolResult.AuthorizationDenied result variant (SPI)
 
 `org.apache.camel.component.ai.tool.AiToolResult` is a sealed interface and 
gained a new variant,

Reply via email to