Conversation
|
🌟 Thank you for your contribution to the Apache Camel project! 🌟 🐫 Apache Camel Committers, please review the following items:
|
gnodet-bot
left a comment
There was a problem hiding this comment.
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 toAbstractExchangecopy 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
callingExchangecaptured intoToolCallback'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
left a comment
There was a problem hiding this comment.
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 resultbefore). - Caller headers reach the tool route, including an inbound HTTP request's
AuthorizationandCamelHttpUri/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.executehas no@paramfor the newcallingExchange.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
| 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
cd94b25 to
f5c74b3
Compare
gnodet-bot
left a comment
There was a problem hiding this comment.
Re-review after f5c74b3. Checking the five findings from the previous davsclaus review:
- Missing
ExchangeHelperimport → ✅ Addressed. - 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. - Missing upgrade guide entry → ✅ Addressed. Covers all three runtimes.
- 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. - 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 (viaconsumer.releaseExchange()) in a try-finally block." — no caller does that anymore, they all usecreateToolExchange()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 { |
There was a problem hiding this comment.
📝 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.
f5c74b3 to
cd63eb1
Compare
gnodet-bot
left a comment
There was a problem hiding this comment.
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:
- Missing
ExchangeHelperimport → ✅ Addressed. - Body/headers leaking into tool routes → ✅ Addressed.
createToolExchange()copies then clears body and headers. - Missing upgrade guide entry → ✅ Addressed. Covers all three runtimes and the langchain4j behaviour change.
- Missing route-tool wiring test → ✅ Addressed.
McpToolCallExecutorTest.routeToolSeesCallerExchangePropertyAndGetsACleanMessagedrives the openai wiring end-to-end. - 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 (viaconsumer.releaseExchange()) in a try-finally block." No caller doesreleaseExchange()anymore — they all usecreateToolExchange(). Should say the caller obtains the exchange viacreateToolExchangeand the copy is not pooled, so it does not need to be released. - Missing
@param callingExchangeonMcpToolCallExecutor.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 { |
There was a problem hiding this comment.
📝 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>
cd63eb1 to
5a5b4f9
Compare
gnodet-bot
left a comment
There was a problem hiding this comment.
Re-review after 5a5b4f9 (squash rewrite). Checking the outstanding findings from previous reviews:
- Missing
ExchangeHelperimport → ✅ Addressed. - Body/headers leaking into tool routes → ✅ Addressed.
- Missing upgrade guide entry → ✅ Addressed.
- Missing route-tool wiring test → ✅ Addressed (
McpToolCallExecutorTest.routeToolSeesCallerExchangePropertyAndGetsACleanMessage). - 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 (viaconsumer.releaseExchange()) in a try-finally block." No caller doesreleaseExchange()anymore — they all usecreateToolExchange()which produces a non-pooled copy. - Missing
@param callingExchangeonMcpToolCallExecutor.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 { |
There was a problem hiding this comment.
📝 Missing @param (re-raised from previous review). The javadoc (lines 106–111) documents toolCalls but not the new callingExchange parameter. Add:
| 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 routebetween the @param toolCalls and @return lines.
|
🧪 CI tested the following changed modules:
🔬 Scalpel shadow comparison — Scalpel: 14 of 697 tested, 25 compile-only — current: 14 all testedMaveniverse 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)
Modules with tests skipped (25)
All tested modules (41 modules, 7m 42s total)Total reactor time: 7m 42s
Top 20 slowest modules:
|
davsclaus
left a comment
There was a problem hiding this comment.
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 viaconsumer.releaseExchange()in a try-finally. No caller does that any more, so please update it.McpToolCallExecutor.executeis 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.toToolCallbackwent 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.
| Exchange toolExchange = ExchangeHelper.createCopy(callingExchange, true); | ||
| toolExchange.getMessage().setBody(null); |
There was a problem hiding this comment.
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:
| 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
Problem
The
camel-ai-toolcontract — 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.subjectand the model cannot forge it.That held for only one runtime:
ExchangeHelper.createCopy(exchange, true), CAMEL-23944).McpToolCallExecutor).AiToolSpecToSpringAi).So with openai or spring-ai driving the loop, a tool route guarded on
exchangeProperty.subjectsawnullanddenied 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 hithardest.
Change
A shared helper
AiToolExecutor.createToolExchange(callingExchange)in camel-ai-tool builds the toolexchange 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:
execute()), not the caller's body or inboundheaders;
No resultrather than echoing the caller's body back to the model;Wiring:
execute → executeOne → executeRouteTooland build thetool exchange from it.
getToolCallbacksForTags → discoverAiRegistryTools → toToolCallbackand capture it in the tool callback closure.
createCopywith the shared helper.The copy is not a pooled consumer exchange, so the now-incorrect
releaseExchange()calls on the openai andspring-ai paths are removed — matching langchain4j, which never released the copy.
Behaviour change (langchain4j)
Previously, a
langchain4j-agentroute tool received a full copy of the caller's message (body + inboundheaders). 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 copycarries 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-toolwiring): drives
executor.execute(...)with a calling exchange carryingCamelAuthenticatedSubject=aliceandasserts 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 -DskipITson camel-ai-tool, camel-openai, camel-langchain4j-agent and camel-spring-ai-chat isgreen (559 tests). No
@UriParam/metadata change; the only generated file touched is the catalog mirror of thecomponent doc.
Related: CAMEL-23944. The MCP server bridge (
McpServerBridge) has no calling exchange and is tracked separatelyby CAMEL-24831.
🤖 Generated with Claude Code