Skip to content

CAMEL-24832: camel-openai, camel-spring-ai-chat - propagate the caller's context into ai-tool route invocations - #27264

Open
oscerd wants to merge 1 commit into
apache:mainfrom
oscerd:fix/CAMEL-24832
Open

oscerd wants to merge 1 commit into
apache:mainfrom
oscerd:fix/CAMEL-24832

Conversation

@oscerd

@oscerd oscerd commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The camel-ai-tool contract — documented in "Authorizing what an AI agent may do in Apache Camel" (CAMEL-23944)
— is that the context the caller set before the agent ran reaches each tool route: most importantly an
authenticated caller's identity kept as an exchange property, so a tool route can be guarded on
exchangeProperty.subject and the model cannot forge it.

That held for only one runtime:

  • camel-langchain4j-agent carries context (ExchangeHelper.createCopy(exchange, true), CAMEL-23944).
  • camel-openai created a fresh exchange (McpToolCallExecutor).
  • camel-spring-ai-chat created a fresh exchange (AiToolSpecToSpringAi).

So with openai or spring-ai driving the loop, a tool route guarded on exchangeProperty.subject saw null and
denied every call (fail-closed, but silently broken), and correlation ids / tenant / variables were lost too.
The no-Java-bean YAML path (openai:chat-completion?tags=... against an OpenAI-compatible endpoint) is hit
hardest.

Change

A shared helper AiToolExecutor.createToolExchange(callingExchange) in camel-ai-tool builds the tool
exchange the same way for all three runtimes, so they can't drift again. It copies the caller's exchange
properties and variables
, then gives the tool route a clean message:

  • the route sees only its own arguments (set as headers by execute()), not the caller's body or inbound
    headers;
  • a tool that sets no body returns No result rather than echoing the caller's body back to the model;
  • changes the tool makes are isolated to the copy and do not leak back into the calling exchange.

Wiring:

  • camel-openai: thread the calling exchange through execute → executeOne → executeRouteTool and build the
    tool exchange from it.
  • camel-spring-ai-chat: thread it through getToolCallbacksForTags → discoverAiRegistryTools → toToolCallback
    and capture it in the tool callback closure.
  • camel-langchain4j-agent: replace its inline createCopy with the shared helper.

The copy is not a pooled consumer exchange, so the now-incorrect releaseExchange() calls on the openai and
spring-ai paths are removed — matching langchain4j, which never released the copy.

Behaviour change (langchain4j)

Previously, a langchain4j-agent route tool received a full copy of the caller's message (body + inbound
headers). It now receives a clean message (its own arguments only). openai and spring-ai route tools
previously got a fresh exchange with no caller context at all, so for them this is purely additive (a bug fix).
Documented in the 4.23 upgrade guide.

Tests

Two tests:

  • AiToolExecutorTest.createToolExchangeCopiesCallerContextButGivesACleanMessage (the shared helper): the copy
    carries the caller's property (authenticated subject) and variables; the message is clean (no caller body, no
    caller inbound header); and tool-side changes do not leak back to the caller. Revert-to-red verified — reverting
    the helper to a plain full copy turns the body/inbound-header assertions red while the context assertions stay
    green.
  • McpToolCallExecutorTest.routeToolSeesCallerExchangePropertyAndGetsACleanMessage (the openai route-tool
    wiring): drives executor.execute(...) with a calling exchange carrying CamelAuthenticatedSubject=alice and
    asserts the tool route reads that exchange property (so the calling exchange is copied in, not a fresh one) and
    that a tool which sets no body returns No result (not the caller's prompt).

mvn clean install -DskipITs on camel-ai-tool, camel-openai, camel-langchain4j-agent and camel-spring-ai-chat is
green (559 tests). No @UriParam/metadata change; the only generated file touched is the catalog mirror of the
component doc.

Related: CAMEL-23944. The MCP server bridge (McpServerBridge) has no calling exchange and is tracked separately
by CAMEL-24831.

🤖 Generated with Claude Code

@oscerd
oscerd requested review from Croway and davsclaus October 2, 2026 07:56
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

🌟 Thank you for your contribution to the Apache Camel project! 🌟
🤖 CI automation will test this PR automatically.

🐫 Apache Camel Committers, please review the following items:

  • First-time contributors require MANUAL approval for the GitHub Actions to run
  • You can use the command /component-test (camel-)component-name1 (camel-)component-name2.. to request a test from the test bot although they are normally detected and executed by CI.
  • You can label PRs using skip-tests and test-dependents to fine-tune the checks executed by this PR.
  • Build and test logs are available in the summary page. Only Apache Camel committers have access to the summary.

⚠️ Be careful when sharing logs. Review their contents before sharing them publicly.

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid change — unifies exchange propagation for AI tool invocations across all three runtimes (langchain4j-agent, openai, spring-ai-chat) behind a single AiToolExecutor.createToolExchange() helper, preventing them from drifting apart again.

Verified:

  • Exchange isolation: ExchangeHelper.createCopy() (which delegates to AbstractExchange copy constructor) creates a proper deep copy — properties and variables are carried, message/body/exception are isolated. The test confirms this.
  • Thread safety for parallel tool calls (openai executeParallel): exchange.copy() only reads from the parent exchange, which is idle during tool execution. Concurrent copies are safe.
  • releaseExchange() removal: correct — createCopy() produces a standalone exchange, not a pooled consumer exchange. Matches langchain4j-agent, which never released copies.
  • Spring AI closure capture: the callingExchange captured in toToolCallback's lambda is copied fresh on each invocation — no shared mutable state.

No issues.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for picking this up, @oscerd. Making the route-tool runtimes behave the same through one shared createToolExchange helper is the right shape, and dropping releaseExchange() is correct since the copy is not a pooled consumer exchange. A few things need attention before this can go in.

Blocking

1. The PR does not compile. AiToolExecutor.java uses ExchangeHelper but lacks import org.apache.camel.support.ExchangeHelper; (see the inline comment). Built at cd94b25739e7:

AiToolExecutor.java:[191,16] cannot find symbol: variable ExchangeHelper

2. Tool routes now receive the caller's body and headers too, not only properties and variables. The copy is isolated in one direction (changes do not leak back to the caller), but it starts with the caller's whole message. A probe against camel-openai (with the import added locally): the caller sent body silent, headers CamelHttpUri=/inbound/path and Authorization: Bearer caller-token, and property subject=alice:

PROBE[silent]  -> silent                                   (tool route only logs)
PROBE[headers] -> uri=/inbound/path auth=Bearer caller-token
PROBE[props]   -> subject=alice
  • A tool route that never sets a body now returns the user's prompt to the model as the tool result (it was No result before).
  • Caller headers reach the tool route, including an inbound HTTP request's Authorization and CamelHttpUri/CamelHttpPath/CamelHttpQuery. A tool route that calls an HTTP endpoint can pick those up and go to the wrong URI or forward credentials.
  • A caller header whose name matches an optional tool argument shows up as that argument when the model leaves it out.

langchain4j-agent has behaved this way since CAMEL-23944, but that fix was about side effects leaking back. For openai and spring-ai this is new. Suggestion: have createToolExchange clear the copied message's headers and body, keeping properties and variables. That matches the new doc section and is all the property-based authorization pattern needs. It also changes langchain4j-agent, so it belongs in the upgrade guide. Either way the doc should say exactly what reaches the tool route.

Also needed

3. Upgrade guide. On openai and spring-ai, tool routes now inherit the caller's properties and variables (plus headers and body unless 2 changes that) and share its exchange id. That is a behaviour change for existing users, so it needs an entry in camel-4x-upgrade-guide-4_23.adoc, per the Code Quality rules in CLAUDE.md.

4. The fix itself is not tested. createToolExchangeCopiesCallerContextAndIsolatesHeadersAndBody only tests the helper. If the old fresh-exchange code came back in McpToolCallExecutor or AiToolSpecToSpringAi, every test would still pass. A route-tool test for each runtime that reads ${exchangeProperty.subject} would cover it. In camel-openai that is about 20 lines, following OpenAIRouteToolReturnDirectTest.

5. Stale javadoc. AiToolExecutor.execute() (line 64) still says the caller must create the exchange "and release it afterwards (via consumer.releaseExchange()) in a try-finally block". No caller does that any more.

Questions

  • createCopy(exchange, true) keeps the caller's exchange id, so with parallel tool execution every tool exchange in a batch shares it. Is that intended for tracing and correlation?
  • Minor: the javadoc of McpToolCallExecutor.execute has no @param for the new callingExchange.

With the import added locally, the existing tests of camel-ai-tool (119), camel-openai (352), camel-spring-ai-chat (13) and camel-langchain4j-agent (74) all pass, so nothing else breaks.

This is a rules-and-conventions review and does not replace specialized review tools or static analysis.

Claude Code on behalf of davsclaus

This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

* @return an isolated copy to pass to {@link #execute(AiToolSpec, Map, Exchange)}
*/
public static Exchange createToolExchange(Exchange callingExchange) {
return ExchangeHelper.createCopy(callingExchange, true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExchangeHelper is not imported, so camel-ai-tool fails to compile (cannot find symbol: variable ExchangeHelper). The import block needs:

import org.apache.camel.support.ExchangeHelper;

Also, createCopy copies the caller's whole message (body + headers) along with properties and variables. See point 2 in the review summary for why clearing the copied message here may be preferable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed.

The import org.apache.camel.support.ExchangeHelper; is restored — that one was my mistake: a revert-to-red mutation left the import unused, impsort stripped it, and I pushed without rebuilding. The module compiles again (verified with a full mvn clean install -DskipITs on the four AI modules).

On the second point: createToolExchange no longer copies the caller's whole message. It copies only the caller's exchange properties and variables, then clears the message (body null, inbound headers cleared), so the tool route starts from its own arguments only — and a tool that sets no body returns No result again, not the caller's prompt. Pinned by AiToolExecutorTest.createToolExchangeCopiesCallerContextButGivesACleanMessage; revert-to-red verified (reverting to a plain full copy turns the body/inbound-header assertions red while the context assertions stay green).

Claude Code on behalf of oscerd

Comment on lines +366 to +367
The tool's own *headers*, *body* and any *exception* are isolated to the copy and do not leak back into the calling
exchange. The tool arguments supplied by the model are placed on this copy before the route runs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads as if the tool route starts without the caller's headers and body, but with exchange.copy() it gets both: the caller's body (often the user prompt) and every caller header, including inbound HTTP headers. Isolation only holds in the other direction. Please either clear them in createToolExchange, or say here exactly what the tool route receives.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the doc now matches the behaviour. The helper clears the copied message, and the "The calling exchange" section now says explicitly that the tool route receives only its own arguments (set as headers), not the caller's body or inbound headers, and that a tool which sets no body returns No result. Only the caller's exchange properties and variables carry over, so a route can still be guarded on exchangeProperty.subject. The catalog mirror is regenerated to match.

Claude Code on behalf of oscerd

// 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before this change the tool exchange started empty. Now a route tool that never sets a body returns the caller's body (the user prompt) to the model instead of No result, and sees all caller headers. A test here that asserts the tool route reads ${exchangeProperty.subject} would also guard against regressing back to a fresh exchange. Nothing tests this wiring today.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed on both counts.

Behaviour: the tool exchange no longer starts from the caller's message. createToolExchange copies the caller's properties and variables, then clears the message — so a route tool that never sets a body returns No result (not the user prompt) and does not see the caller's inbound headers.

Test: added McpToolCallExecutorTest.routeToolSeesCallerExchangePropertyAndGetsACleanMessage, which exercises this exact openai wiring: it registers an ai-tool: route, drives executor.execute(...) with a calling exchange carrying CamelAuthenticatedSubject=alice, and asserts (a) the tool route reads that exchange property (= alice), so the calling exchange is copied in — regressing to a fresh exchange turns this red — and (b) a no-body tool returns No result. The shared-helper contract is additionally pinned by AiToolExecutorTest.createToolExchangeCopiesCallerContextButGivesACleanMessage (revert-to-red verified).

Claude Code on behalf of oscerd

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review after f5c74b3. Checking the five findings from the previous davsclaus review:

  1. Missing ExchangeHelper import → ✅ Addressed.
  2. Body/headers leaking into tool routes → ✅ Addressed. createToolExchange() now clears body and headers after copy. The doc section accurately describes what reaches the tool route.
  3. Missing upgrade guide entry → ✅ Addressed. Covers all three runtimes.
  4. Missing route-tool wiring test → The helper unit test (createToolExchangeCopiesCallerContextButGivesACleanMessage) is good but davsclaus asked for a route-tool test per runtime that reads ${exchangeProperty.subject}. Only the helper is tested.
  5. Stale javadoc on AiToolExecutor.execute() → ❌ Still present. Line 66 says: "The calling adapter owns the exchange lifecycle: it must create the exchange before calling this method and release it afterwards (via consumer.releaseExchange()) in a try-finally block." — no caller does that anymore, they all use createToolExchange() which produces a non-pooled copy.

The code change itself is solid — the centralization in createToolExchange() with explicit body/header clearing is the right design.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

* @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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Minor: the @param callingExchange is missing from this method's javadoc (lines 105–110). The toolCalls param is documented but the new parameter is not.

@oscerd oscerd changed the title CAMEL-24832: camel-openai, camel-spring-ai-chat - propagate the calling exchange into ai-tool route invocations CAMEL-24832: camel-openai, camel-spring-ai-chat - propagate the caller's context into ai-tool route invocations Oct 2, 2026

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review after cd63eb1 (squashed rewrite). Checking the five findings from the davsclaus CHANGES_REQUESTED review and the two from the previous gnodet-bot COMMENT review:

  1. Missing ExchangeHelper import → ✅ Addressed.
  2. Body/headers leaking into tool routes → ✅ Addressed. createToolExchange() copies then clears body and headers.
  3. Missing upgrade guide entry → ✅ Addressed. Covers all three runtimes and the langchain4j behaviour change.
  4. Missing route-tool wiring test → ✅ Addressed. McpToolCallExecutorTest.routeToolSeesCallerExchangePropertyAndGetsACleanMessage drives the openai wiring end-to-end.
  5. Stale javadoc on AiToolExecutor.execute() → ❌ Still present. Line 64 says: "The calling adapter owns the exchange lifecycle: it must create the exchange before calling this method and release it afterwards (via consumer.releaseExchange()) in a try-finally block." No caller does releaseExchange() anymore — they all use createToolExchange(). Should say the caller obtains the exchange via createToolExchange and the copy is not pooled, so it does not need to be released.
  6. Missing @param callingExchange on McpToolCallExecutor.execute() → ❌ Still missing (see inline).

The code change itself is solid and well-tested. Two low-severity javadoc nits remain from the previous round.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

* @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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Missing @param (re-raised from previous review). The javadoc (lines 106–111) documents toolCalls but not the new callingExchange parameter. Add:

@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

…r's context into ai-tool route invocations

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. 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 now receive a clean message. Documented in the 4.23 upgrade guide.

Documented the contract in the camel-ai-tool component doc ("The calling exchange"). Tests:
AiToolExecutorTest.createToolExchangeCopiesCallerContextButGivesACleanMessage (helper - properties and variables
carried; message clean, no caller body or inbound headers; tool-side changes isolated; revert-to-red verified)
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 <noreply@anthropic.com>
Signed-off-by: Andrea Cosentino <ancosen@gmail.com>

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review after 5a5b4f9 (squash rewrite). Checking the outstanding findings from previous reviews:

  1. Missing ExchangeHelper import → ✅ Addressed.
  2. Body/headers leaking into tool routes → ✅ Addressed.
  3. Missing upgrade guide entry → ✅ Addressed.
  4. Missing route-tool wiring test → ✅ Addressed (McpToolCallExecutorTest.routeToolSeesCallerExchangePropertyAndGetsACleanMessage).
  5. Stale javadoc on AiToolExecutor.execute() → ❌ Still present. Line 64 says: "The calling adapter owns the exchange lifecycle: it must create the exchange before calling this method and release it afterwards (via consumer.releaseExchange()) in a try-finally block." No caller does releaseExchange() anymore — they all use createToolExchange() which produces a non-pooled copy.
  6. Missing @param callingExchange on McpToolCallExecutor.execute() → ❌ Still missing (see inline).

The code change itself is solid and well-tested. Two low-severity javadoc nits remain from the previous rounds.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

* @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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Missing @param (re-raised from previous review). The javadoc (lines 106–111) documents toolCalls but not the new callingExchange parameter. Add:

Suggested change
List<ToolResult> execute(List<ChatCompletionMessageToolCall> toolCalls, Exchange callingExchange) throws Exception {
List<ToolResult> execute(List<ChatCompletionMessageToolCall> toolCalls, Exchange callingExchange) throws Exception {

Can't suggest the javadoc addition itself since those lines aren't in the diff, but please add:

     * @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

between the @param toolCalls and @return lines.

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • catalog/camel-catalog
  • components/camel-ai/camel-ai-tool
  • components/camel-ai/camel-langchain4j-agent
  • components/camel-ai/camel-openai
  • components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat
  • docs

🔬 Scalpel shadow comparison — Scalpel: 14 of 697 tested, 25 compile-only — current: 14 all tested

Maveniverse Scalpel detected 14 affected modules (current approach: 14).

Skip-tests mode would test 14 modules (6 direct + 10 downstream), skip tests for 25 (generated code, meta-modules)

Modules Scalpel would test (14)
  • camel-ai-tool ← components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc, components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java, components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolExecutorTest.java
  • camel-jbang-mcp ← downstream of org.apache.camel:camel-catalog
  • camel-jbang-plugin-mcp ← downstream of org.apache.camel:camel-jbang-core
  • camel-jbang-plugin-route-parser ← downstream of org.apache.camel:camel-route-parser
  • camel-jbang-plugin-tui ← downstream of org.apache.camel:camel-catalog
  • camel-jbang-plugin-validate ← downstream of org.apache.camel:camel-yaml-dsl-validator
  • camel-langchain4j-agent ← components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java
  • camel-launcher-container ← downstream of org.apache.camel:camel-launcher
  • camel-mcp-server ← downstream of org.apache.camel:camel-openai
  • camel-mcp-server-api ← downstream of org.apache.camel:camel-ai-tool
  • camel-openai ← components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/McpToolCallExecutor.java, components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java, components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIResponsesProducer.java, components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIToolExecutionProducer.java, components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/McpToolCallExecutorTest.java
  • camel-spring-ai-chat ← components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/AiToolSpecToSpringAi.java, components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatProducer.java
  • camel-yaml-dsl-validator ← downstream of org.apache.camel:camel-catalog
  • camel-yaml-dsl-validator-maven-plugin ← downstream of org.apache.camel:camel-yaml-dsl-validator
Modules with tests skipped (25)
  • apache-camel
  • camel-allcomponents
  • camel-catalog-console
  • camel-catalog-maven
  • camel-catalog-suggest
  • camel-componentdsl
  • camel-endpointdsl
  • camel-endpointdsl-support
  • camel-itest
  • camel-jbang-core
  • camel-jbang-it
  • camel-jbang-main
  • camel-jbang-plugin-edit
  • camel-jbang-plugin-generate
  • camel-jbang-plugin-kubernetes
  • camel-jbang-plugin-test
  • camel-kamelet-main
  • camel-launcher
  • camel-report-maven-plugin
  • camel-route-parser
  • camel-yaml-dsl
  • camel-yaml-dsl-deserializers
  • camel-yaml-dsl-maven-plugin
  • coverage
  • dummy-component

ℹ️ Shadow mode — Scalpel observes but does not affect test execution. Learn more

⚠️ Some tests are disabled on GitHub Actions (@DisabledIfSystemProperty(named = "ci.env.name")) and require manual verification:

  • components/camel-ai/camel-langchain4j-agent: 16 test(s) disabled on GitHub Actions
  • components/camel-ai/camel-openai: 7 test(s) disabled on GitHub Actions
  • components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat: 21 test(s) disabled on GitHub Actions
All tested modules (41 modules, 7m 42s total)

Total reactor time: 7m 42s

Module Duration Status
Camel :: AI :: MCP Server 48.3s SUCCESS
Camel :: Launcher 46.6s SUCCESS
Camel :: AI :: OpenAI 40.4s SUCCESS
Camel :: JBang :: Plugin :: TUI 40.1s SUCCESS
Camel :: JBang :: MCP 37.1s SUCCESS
Camel :: Component DSL 34.4s SUCCESS
Camel :: Catalog :: Camel Catalog 21.9s SUCCESS
Camel :: AI :: MCP Server API 17.9s SUCCESS
Camel :: JBang :: Plugin :: Kubernetes 17.5s SUCCESS
Camel :: YAML DSL 16.5s SUCCESS
Camel :: YAML DSL :: Validator 16.4s SUCCESS
Camel :: AI :: LangChain4j :: Agent 15.8s SUCCESS
Camel :: Docs 14.3s SUCCESS
Camel :: Spring AI :: Chat 13.5s SUCCESS
Camel :: Kamelet Main 11.5s SUCCESS
Camel :: AI :: Tool 10.6s SUCCESS
Camel :: JBang :: Plugin :: Testing 7.2s SUCCESS
Camel :: YAML DSL :: Deserializers 7.0s SUCCESS
Camel :: Catalog :: Camel Route Parser 6.7s SUCCESS
Camel :: Catalog :: Camel Report Maven Plugin 6.0s SUCCESS
Camel :: All Components Sync point 4.9s SUCCESS
Camel :: JBang :: Plugin :: Validate 4.8s SUCCESS
Camel :: YAML DSL :: Validator Maven Plugin 3.8s SUCCESS
Camel :: Catalog :: Maven 2.7s SUCCESS
Camel :: YAML DSL :: Maven Plugins 2.6s SUCCESS
Camel :: Catalog :: Suggest (deprecated) 2.1s SUCCESS
Camel :: Assembly 1.6s SUCCESS
Camel :: Coverage 1.4s SUCCESS
Camel :: JBang :: Plugin :: Edit 1.2s SUCCESS
Camel :: Catalog :: Dummy Component 1.1s SUCCESS
Camel :: JBang :: Plugin :: Generate 0.9s SUCCESS
Camel :: Catalog :: Console 0.9s SUCCESS
Camel :: JBang :: Integration tests 0.8s SUCCESS
Camel :: Endpoint DSL :: Support 0.8s SUCCESS
Camel :: JBang :: Main 0.8s SUCCESS
Camel :: Launcher :: Container 0.7s SUCCESS
Camel :: JBang :: Plugin :: MCP 0.7s SUCCESS
Camel :: JBang :: Plugin :: Route Parser 0.5s SUCCESS
Camel :: Endpoint DSL n/a
Camel :: Integration Tests n/a
Camel :: JBang :: Core n/a

Top 20 slowest modules:

  • Camel :: AI :: MCP Server (48.3s)
  • Camel :: Launcher (46.6s)
  • Camel :: AI :: OpenAI (40.4s)
  • Camel :: JBang :: Plugin :: TUI (40.1s)
  • Camel :: JBang :: MCP (37.1s)
  • Camel :: Component DSL (34.4s)
  • Camel :: Catalog :: Camel Catalog (21.9s)
  • Camel :: AI :: MCP Server API (17.9s)
  • Camel :: JBang :: Plugin :: Kubernetes (17.5s)
  • Camel :: YAML DSL (16.5s)
  • Camel :: YAML DSL :: Validator (16.4s)
  • Camel :: AI :: LangChain4j :: Agent (15.8s)
  • Camel :: Docs (14.3s)
  • Camel :: Spring AI :: Chat (13.5s)
  • Camel :: Kamelet Main (11.5s)
  • Camel :: AI :: Tool (10.6s)
  • Camel :: JBang :: Plugin :: Testing (7.2s)
  • Camel :: YAML DSL :: Deserializers (7.0s)
  • Camel :: Catalog :: Camel Route Parser (6.7s)
  • Camel :: Catalog :: Camel Report Maven Plugin (6.0s)

⚙️ View full build and test results

@oscerd
oscerd requested a review from davsclaus October 2, 2026 11:22

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @oscerd, the blocking points are fixed: the compile error, the tool exchange now starts with a clean message (properties and variables only) in all three runtimes, and the upgrade guide covers them. A few small things before I approve:

  • AiToolExecutor.execute() javadoc still says the calling adapter must create the exchange and release it via consumer.releaseExchange() in a try-finally. No caller does that any more, so please update it.
  • McpToolCallExecutor.execute is still missing @param callingExchange (gnodet-bot raised it twice).
  • My earlier question on the exchange id: createCopy(..., true) keeps the caller's id, so every parallel tool exchange in a batch has the same id. Is that intended? A sentence in the docs either way would help.

Optional:

  • camel-spring-ai-chat has no wiring test. If AiToolSpecToSpringAi.toToolCallback went back to a fresh exchange, nothing would fail. Same for langchain4j-agent.
  • The copy also shares the caller's UnitOfWork (and internal properties) across parallel tool calls. That's how langchain4j-agent worked already, but it's new for openai and spring-ai; the "The calling exchange" doc section could mention it.

Claude Code on behalf of davsclaus. This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

Comment on lines +198 to +199
Exchange toolExchange = ExchangeHelper.createCopy(callingExchange, true);
toolExchange.getMessage().setBody(null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more thing about the copy, which builds on @davsclaus's notes about the shared exchange id and UnitOfWork. createCopy(callingExchange, true) keeps the caller's UnitOfWork (AbstractExchange copies unitOfWork) and its exchange id. So the tool route runs inside the caller's UoW, and that changes how tool routes behave compared with the fresh exchange openai/spring-ai used before.

I probed it in camel-ai-tool. A direct:caller route calls AiToolExecutor.execute with body caller-prompt and an inbound header:

tool route fresh exchange (main) createToolExchange (this PR)
has onCompletion().process(...) runs right after the tool call never runs, not even after the caller completes
exchange id in 3 parallel calls 3 distinct ids all the caller's id
errorHandler(deadLetterChannel("mock:dlq").useOriginalMessage()), route throws DLQ body null; tool result No result DLQ gets the caller's body and headers; tool result caller-prompt

The last row undoes the clean message: useOriginalMessage() restores the UoW's original message, which is the caller's. The DLC handles the exception, so execute returns the restored body to the model.

Giving the copy its own UoW (and its own id) fixes all three rows:

Suggested change
Exchange toolExchange = ExchangeHelper.createCopy(callingExchange, true);
toolExchange.getMessage().setBody(null);
Exchange toolExchange = callingExchange.copy();
// the tool route runs in its own unit of work (onCompletion, useOriginalMessage, inflight), not in the caller's
toolExchange.getExchangeExtension().setUnitOfWork(null);
toolExchange.getMessage().setBody(null);

(and the ExchangeHelper import goes away). With that, the camel-ai-tool tests (120, including createToolExchangeCopiesCallerContextButGivesACleanMessage) and camel-openai's McpToolCallExecutorTest (12, including routeToolSeesCallerExchangePropertyAndGetsACleanMessage) pass. Each tool call gets its own exchange id, its onCompletion runs once, and the DLQ gets the tool's own original message. That would also settle the exchange-id question: the id is no longer shared. If you want to keep a link to the caller, ExchangePropertyKey.CORRELATION_ID can carry the caller's id, as split/multicast do. The "The calling exchange" doc section and the upgrade guide could then say the tool route runs in its own unit of work.

Claude Code on behalf of allthingssecurity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants