CAMEL-23944: Fix ToolExecutionErrorHandler never invoked for Camel route tools - #24992
Conversation
|
🌟 Thank you for your contribution to the Apache Camel project! 🌟 🐫 Apache Camel Committers, please review the following items:
|
|
🧪 CI tested the following changed modules:
🔬 Scalpel shadow comparison — Scalpel: 10 tested, 28 compile-only — current: 9 all testedMaveniverse Scalpel detected 38 affected modules (current approach: 9).
|
gnodet
left a comment
There was a problem hiding this comment.
Claude Code on behalf of gnodet
Review Summary
Solid bug fix for three interrelated issues in the ToolExecutor created by LangChain4jAgentProducer for Camel route tools. The fix is well-scoped, follows established patterns in the codebase, and the test is a proper reproducer that fails without the fix.
What I Love
-
Consistent pattern with
LangChain4jToolsProducer— The fix usesExchangeHelper.createCopy(exchange, true)for exchange isolation, which is the exact same pattern used inLangChain4jToolsProducer(lines 125 and 219). Matching established patterns reduces cognitive load for future maintainers. -
Thorough root cause analysis — The PR identifies all three bugs (missing exception check, exception swallowing, shared exchange leakage) and addresses them together. Fixing only one or two would have left subtle failure modes.
-
Clever test architecture — The
Agentimplementation that captures theToolProviderand exercises theToolExecutordirectly is an elegant way to test the tool execution path without needing a real LLM. The test design cleanly separates what it is testing (ToolExecutor behavior) from what it does not care about (exchange-level propagation), with appropriatetry-catchand comments explaining why.
Findings
Critical
None — nice work!
Important
None.
Suggestions & Nits
[Nit] The LOG.error(...) call in the catch block (line ~335 in the diff) means tool failures will be logged here and by LangChain4j's error handler. This is a very minor redundancy — it is arguably better to log too much than too little in error scenarios, and it is consistent with the original behavior. No change needed.
[Suggestion] The state-isolation test (toolExecutionShouldNotLeakStateIntoCallingExchange) covers the success path. For completeness, a variant that sends through the failing-tool route and verifies the calling exchange's body/headers remain untouched after the failure would strengthen coverage of the isolation fix. That said, the current test is sufficient because the createCopy call is unconditional — it runs on every tool invocation regardless of success/failure. This is entirely optional.
[Question] In LangChain4jToolsProducer, after tool execution, results are copied back to the original exchange via ExchangeHelper.copyResults(exchange, toolExchange) (line 272). The agent producer intentionally does not do this — the tool result is extracted as a String through the ToolExecutor return value, and the original exchange stays clean. I verified this is correct for the agent use case since the Agent API returns a Result<String> that carries the final response. Just confirming: was there a consideration about whether any exchange properties set during tool execution should be propagated back? If not needed, the current approach (no copyResults) is the right call.
Verification
I verified the following during review:
- Exchange copy pattern:
ExchangeHelper.createCopy(exchange, true)matches the pattern inLangChain4jToolsProducer(confirmed at lines 125 and 219 of that file). Thetrueparameter preserves the exchange ID for tracing/logging correlation. - Exception propagation flow: After
process(toolExchange), iftoolExchange.getException() != null, the exception is thrown. It falls into thecatch (Exception e)block, which wraps it inRuntimeExceptionand rethrows. SinceToolExecutor.execute()does not declare checked exceptions,RuntimeExceptionis the correct wrapper. - No state leaks: All header mutations and body reads operate on
toolExchange, not the originalexchange. The original exchange is only read (never written) inside the lambda after the fix. - Normal path preserved: When the tool succeeds,
toolExchange.getException()is null, the body is extracted as String, and the result is returned. The"No result"fallback for null body is retained. - Test quality: All 4 tests are focused, use AssertJ with descriptive
as(...)messages, and the reproducer tests (shouldThrow...,shouldPropagateExceptionMessage...) correctly wrapsendBodyin try-catch since the exception may propagate through the exchange.
Overall
Clean fix, clean test, clean PR description. The three bugs were real, the fix is correct and follows established patterns, and the test provides good coverage. This is ready to merge.
|
you need to rebase and fix merge conflicts |
oscerd
left a comment
There was a problem hiding this comment.
The diagnosis here is correct and the bug is real — but this branch has been overtaken by main and needs a rebase before it can be assessed on its merits.
Why it's blocked
mergeable: CONFLICTING. The cause isn't a textual clash: commit bfd60dfb361e ("CAMEL-23382: Wire camel-langchain4j-agent to camel-ai-tool registry") landed on main at 2026-07-22 09:48, about 17 hours after this branch's last commit, and it removed the inline ToolExecutor this PR patches. On current main, LangChain4jAgentProducer no longer contains the JSON→header mapping or the allowlist at all — it now delegates:
AiToolResult result = AiToolExecutor.execute(spec, arguments, exchange);with the logic relocated to components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java.
The good news: the bug survived the move, so this work isn't wasted
I checked each of the three defects against current main:
| Defect | Status on main |
Where the rebased fix belongs |
|---|---|---|
Missing exchange.getException() check |
Already fixed — AiToolExecutor checks it |
n/a |
| Exception swallowed as a string | Still present — toToolResponse() returns the literal "Tool execution failed", which is less informative than what it replaced |
toToolResponse() should rethrow on the ExecutionError variant |
| Shared/live exchange | Still present — the live producer exchange is passed straight into AiToolExecutor.execute(...) |
the addToolToResult() path |
The third one is worth emphasising because AiToolExecutor's own javadoc says "The calling adapter owns the exchange lifecycle: it must create the exchange before calling this method" — and the agent adapter doesn't. So CAMEL-23944 is still live; it just needs re-targeting at the new home.
Security note (positive)
For the record, since this touches the header-mapping path: your change strengthens the isolation rather than weakening it. The allowlist guard is untouched — the diff hunk starts after it — and switching exchange.getMessage().setHeader(...) to toolExchange... means allowlisted-but-model-chosen headers no longer persist onto the calling exchange after the agent returns. It also removes a genuine data race, since langchain4j can execute tools concurrently and the old code shared one Exchange across them. That reasoning still applies post-rebase.
Two things to fix while rebasing
- Don't wrap in a plain
RuntimeException. A user'sToolExecutionErrorHandler.handle(Throwable, ToolErrorContext)— the very handler this PR exists to enable — would receive ajava.lang.RuntimeExceptionand have to unwrapgetCause()to type-match the real Camel exception. Rethrow as-is when already unchecked, otherwise wrap inRuntimeCamelException(the Camel idiom, and what the sibling PR #24991 uses). toolExecutionShouldNotLeakStateIntoCallingExchangedoesn't test what it claims. Its only assertion is that the response equals"agent response"— but the capturing agent returns that constant unconditionally and the producer overwrites the body withresult.content()regardless. The test passes identically with and without the fix, so bug 3 currently has zero coverage. It should assert that the calling exchange's original body/headers survive and thatCamelToolNameis absent afterwards.
Also worth an upgrade-guide entry once rebased: exchange isolation is user-visible, since tool-route header/body mutations and the CamelToolName header will stop propagating to the calling exchange.
Requesting changes purely on the staleness — the analysis is sound and I'd like to see it land against the new AiToolExecutor. Separately I've raised the allowlist regression I spotted in that new class as its own issue, since it came from CAMEL-23382 rather than from your work here.
Reviewed with Claude Code (Opus 4.8) on behalf of Andrea Cosentino (@oscerd). This review was generated by an AI agent and may contain inaccuracies; please verify all suggestions before applying.
…ute tools in agent The ToolExecutor in LangChain4jAgentProducer.createCamelToolProvider had two bugs that prevented LangChain4j's error handling from firing when a Camel route tool failed: 1. After process(exchange), the code did not check exchange.getException(). In Camel, route processors store exceptions on the exchange instead of rethrowing, so the exception was silently ignored and the error handlers (ToolExecutionErrorHandler, compensateOnToolErrors) never fired. 2. The catch block returned an error string instead of rethrowing, which made LangChain4j treat the failure as a successful tool result. 3. The ToolExecutor reused the live producer exchange, leaking tool side-effects (headers, body, exceptions) into the calling exchange. Fix: create an isolated exchange copy for each tool invocation, check for exchange exceptions after processing, and rethrow so LangChain4j's error handling machinery kicks in. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Wrap sendBody calls in try-catch in the failing-tool tests so the assertions on errorObserved/capturedErrorMessage are reached regardless of exchange-level exception propagation. This ensures: - Without the fix: tests fail with assertion errors (errorObserved=false) proving the ToolExecutor swallows exceptions - With the fix: tests pass (errorObserved=true) proving the ToolExecutor correctly rethrows for LangChain4j error handlers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…edback Re-targets the CAMEL-23944 fix at the new code structure after CAMEL-23382 moved tool execution from inline ToolExecutor in LangChain4jAgentProducer to the shared AiToolExecutor. Two remaining bugs on current main: 1. Exception swallowed as string — toToolResponse() returned "Tool execution failed" on ExecutionError, making LangChain4j believe the tool succeeded. Fix: rethrow using RuntimeCamelException.wrapRuntimeCamelException() so ToolExecutionErrorHandler / compensateOnToolErrors can fire. 2. Shared exchange — addToolToResult() passed the live producer exchange to AiToolExecutor.execute(), leaking tool side-effects (headers, body) into the calling exchange. Fix: ExchangeHelper.createCopy(exchange, true) isolates each tool invocation. Review feedback addressed: - Use RuntimeCamelException instead of plain RuntimeException (reviewer @oscerd) - Fix toolExecutionShouldNotLeakStateIntoCallingExchange to assert original headers survive and tool argument/side-effect headers do not leak - Add failingToolExecutionShouldNotLeakStateIntoCallingExchange for failure path - Migrate tool URIs from langchain4j-tools: to ai-tool: Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
29c6fd1 to
13446c0
Compare
|
Claude Code on behalf of gnodet @oscerd Thank you for the thorough review — the analysis of the post-CAMEL-23382 state was exactly right. Here's what I've done in the rebase: Rebased onto current
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…change Document that tool invocations now run on isolated exchange copies, so tool headers (arguments, side-effects, CamelToolName) no longer leak into the calling exchange. Also document that tool execution errors are now rethrown as RuntimeCamelException. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Claude Code on behalf of gnodet @oscerd Thank you for the thorough review and the clear breakdown of what survived the CAMEL-23382 migration. All requested changes have been addressed: Rebase onto current main ✅The fix has been re-targeted to RuntimeCamelException instead of plain RuntimeException ✅Changed to Exchange isolation test actually tests what it claims ✅
Added a companion test Upgrade guide entry ✅Added a new section "Exchange isolation for Camel route tools" to the 4.22 upgrade guide (commit
|
Claude Code on behalf of gnodet
Summary
Fix CAMEL-23944:
ToolExecutionErrorHandlerandcompensateOnToolErrorsare never invoked for Camel route tools incamel-langchain4j-agent.Root Cause
After CAMEL-23382 moved tool execution to the shared
AiToolExecutor, two of the three original bugs survived the migration:Exception swallowed as string —
toToolResponse()returned"Tool execution failed"onExecutionError, making LangChain4j believe the tool succeeded.ToolExecutionErrorHandler/compensateOnToolErrorsnever fire.Shared exchange —
addToolToResult()passed the live producer exchange directly toAiToolExecutor.execute(), despite the executor's javadoc requiring"The calling adapter owns the exchange lifecycle: it must create the exchange before calling this method". This leaks tool side-effects (headers, body) into the calling exchange and introduces a data race under concurrent tool execution.(The third original bug — missing
exchange.getException()check — was already fixed inAiToolExecutorby CAMEL-23382.)Fix
RuntimeCamelException.wrapRuntimeCamelException(error.cause())— Rethrows execution errors using the Camel idiom so LangChain4j's error handling machinery fires and downstream error handlers can type-match the real cause without unwrappingExchangeHelper.createCopy(exchange, true)— Isolates each tool invocation in its own exchange copy (same pattern used inLangChain4jToolsProducer)Test Plan
LangChain4jAgentToolExecutorErrorHandlingTest— 5 tests covering:ai-tool:consumer URIs (aligned with CAMEL-23382)🤖 Generated with Claude Code