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,