Skip to content

CAMEL-23944: Fix ToolExecutionErrorHandler never invoked for Camel route tools - #24992

Merged
gnodet merged 5 commits into
apache:mainfrom
gnodet:fix-camel-23944-camel-langchain4j-agent-toolexecu
Jul 23, 2026
Merged

CAMEL-23944: Fix ToolExecutionErrorHandler never invoked for Camel route tools#24992
gnodet merged 5 commits into
apache:mainfrom
gnodet:fix-camel-23944-camel-langchain4j-agent-toolexecu

Conversation

@gnodet

@gnodet gnodet commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Claude Code on behalf of gnodet

Summary

Fix CAMEL-23944: ToolExecutionErrorHandler and compensateOnToolErrors are never invoked for Camel route tools in camel-langchain4j-agent.

Root Cause

After CAMEL-23382 moved tool execution to the shared AiToolExecutor, two of the three original bugs survived the migration:

  1. Exception swallowed as stringtoToolResponse() returned "Tool execution failed" on ExecutionError, making LangChain4j believe the tool succeeded. ToolExecutionErrorHandler / compensateOnToolErrors never fire.

  2. Shared exchangeaddToolToResult() passed the live producer exchange directly to AiToolExecutor.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 in AiToolExecutor by 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 unwrapping
  • ExchangeHelper.createCopy(exchange, true) — Isolates each tool invocation in its own exchange copy (same pattern used in LangChain4jToolsProducer)

Test Plan

  • LangChain4jAgentToolExecutorErrorHandlingTest — 5 tests covering:
    • ToolExecutor throws when Camel route tool fails (CAMEL-23944 reproducer)
    • ToolExecutor returns result when tool succeeds
    • Exception message propagation from failing route
    • No state leakage into calling exchange (success path) — verifies original headers survive, tool argument headers and route side-effect headers do not leak
    • No state leakage into calling exchange (failure path) — verifies isolation is unconditional
  • All tests use ai-tool: consumer URIs (aligned with CAMEL-23382)
  • Upgrade guide entry for exchange isolation behavior change
  • Code formatted

🤖 Generated with Claude Code

@github-actions

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.

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • components/camel-ai/camel-langchain4j-agent
  • docs

🔬 Scalpel shadow comparison — Scalpel: 10 tested, 28 compile-only — current: 9 all tested

Maveniverse Scalpel detected 38 affected modules (current approach: 9).

⚠️ Modules only in Scalpel (29)
  • apache-camel
  • camel-allcomponents
  • camel-catalog
  • camel-catalog-console
  • camel-catalog-lucene
  • camel-catalog-maven
  • camel-catalog-suggest
  • camel-componentdsl
  • camel-csimple-maven-plugin
  • 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
  • docs
  • dummy-component

Skip-tests mode would test 10 modules (2 direct + 8 downstream), skip tests for 28 (generated code, meta-modules)

Modules Scalpel would test (10)
  • camel-jbang-mcp
  • camel-jbang-plugin-mcp
  • camel-jbang-plugin-route-parser
  • camel-jbang-plugin-tui
  • camel-jbang-plugin-validate
  • camel-langchain4j-agent
  • camel-launcher-container
  • camel-yaml-dsl-validator
  • camel-yaml-dsl-validator-maven-plugin
  • docs
Modules with tests skipped (28)
  • apache-camel
  • camel-allcomponents
  • camel-catalog
  • camel-catalog-console
  • camel-catalog-lucene
  • camel-catalog-maven
  • camel-catalog-suggest
  • camel-componentdsl
  • camel-csimple-maven-plugin
  • 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
All tested modules (38 modules)
  • Camel :: AI :: LangChain4j :: Agent
  • Camel :: All Components Sync point
  • Camel :: Assembly
  • Camel :: Catalog :: CSimple Maven Plugin (deprecated)
  • Camel :: Catalog :: Camel Catalog
  • Camel :: Catalog :: Camel Report Maven Plugin
  • Camel :: Catalog :: Camel Route Parser
  • Camel :: Catalog :: Console
  • Camel :: Catalog :: Dummy Component
  • Camel :: Catalog :: Lucene (deprecated)
  • Camel :: Catalog :: Maven
  • Camel :: Catalog :: Suggest
  • Camel :: Component DSL
  • Camel :: Coverage
  • Camel :: Docs
  • Camel :: Endpoint DSL
  • Camel :: Endpoint DSL :: Support
  • Camel :: Integration Tests
  • Camel :: JBang :: Core
  • Camel :: JBang :: Integration tests
  • Camel :: JBang :: MCP
  • Camel :: JBang :: Main
  • Camel :: JBang :: Plugin :: Edit
  • Camel :: JBang :: Plugin :: Generate
  • Camel :: JBang :: Plugin :: Kubernetes
  • Camel :: JBang :: Plugin :: MCP
  • Camel :: JBang :: Plugin :: Route Parser
  • Camel :: JBang :: Plugin :: TUI
  • Camel :: JBang :: Plugin :: Testing
  • Camel :: JBang :: Plugin :: Validate
  • Camel :: Kamelet Main
  • Camel :: Launcher
  • Camel :: Launcher :: Container
  • Camel :: YAML DSL
  • Camel :: YAML DSL :: Deserializers
  • Camel :: YAML DSL :: Maven Plugins
  • Camel :: YAML DSL :: Validator
  • Camel :: YAML DSL :: Validator Maven Plugin

⚙️ View full build and test results

@gnodet
gnodet marked this pull request as ready for review July 21, 2026 18:47

@gnodet gnodet left a comment

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.

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

  1. Consistent pattern with LangChain4jToolsProducer — The fix uses ExchangeHelper.createCopy(exchange, true) for exchange isolation, which is the exact same pattern used in LangChain4jToolsProducer (lines 125 and 219). Matching established patterns reduces cognitive load for future maintainers.

  2. 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.

  3. Clever test architecture — The Agent implementation that captures the ToolProvider and exercises the ToolExecutor directly 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 appropriate try-catch and 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 in LangChain4jToolsProducer (confirmed at lines 125 and 219 of that file). The true parameter preserves the exchange ID for tracing/logging correlation.
  • Exception propagation flow: After process(toolExchange), if toolExchange.getException() != null, the exception is thrown. It falls into the catch (Exception e) block, which wraps it in RuntimeException and rethrows. Since ToolExecutor.execute() does not declare checked exceptions, RuntimeException is the correct wrapper.
  • No state leaks: All header mutations and body reads operate on toolExchange, not the original exchange. 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 wrap sendBody in 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.

@davsclaus

Copy link
Copy Markdown
Contributor

you need to rebase and fix merge conflicts

@oscerd oscerd 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.

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 fixedAiToolExecutor checks it n/a
Exception swallowed as a string Still presenttoToolResponse() 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's ToolExecutionErrorHandler.handle(Throwable, ToolErrorContext) — the very handler this PR exists to enable — would receive a java.lang.RuntimeException and have to unwrap getCause() to type-match the real Camel exception. Rethrow as-is when already unchecked, otherwise wrap in RuntimeCamelException (the Camel idiom, and what the sibling PR #24991 uses).
  • toolExecutionShouldNotLeakStateIntoCallingExchange doesn'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 with result.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 that CamelToolName is 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.

gnodet and others added 3 commits July 22, 2026 15:12
…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>
@gnodet
gnodet force-pushed the fix-camel-23944-camel-langchain4j-agent-toolexecu branch from 29c6fd1 to 13446c0 Compare July 22, 2026 15:17
@gnodet

gnodet commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

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 main

The branch is now based on main after CAMEL-23382. The fix targets the new AiToolExecutor code path:

  • addToolToResult() — Added ExchangeHelper.createCopy(exchange, true) before AiToolExecutor.execute() to isolate each tool invocation (fixing bug 3: shared exchange)
  • toToolResponse() — Changed ExecutionError handling from returning "Tool execution failed" to throw RuntimeCamelException.wrapRuntimeCamelException(error.cause()) (fixing bug 2: exception swallowing)

Addressed review feedback

  1. RuntimeCamelException instead of RuntimeException ✅ — Using RuntimeCamelException.wrapRuntimeCamelException() so downstream ToolExecutionErrorHandler.handle(Throwable, ToolErrorContext) can type-match the real cause without unwrapping.

  2. Fixed toolExecutionShouldNotLeakStateIntoCallingExchange ✅ — The test now:

    • Sends with a known body and originalHeader=originalValue
    • Passes actual tool arguments ({"input": "test value"}) so AiToolExecutor sets argument headers
    • Tool route sets a toolSideEffect header for route-level leakage detection
    • Asserts: original header survives, input argument header is absent, toolSideEffect header is absent
    • Added a separate failingToolExecutionShouldNotLeakStateIntoCallingExchange for the failure path
  3. Tool URIs migrated — All test routes use ai-tool: instead of langchain4j-tools:

Bug 1 (missing exchange.getException() check)

Confirmed already fixed in AiToolExecutor.execute() on main — no action needed.

Re: upgrade guide entry

Good call on the upgrade guide. The exchange isolation change is user-visible (tool-route header/body mutations and tool argument headers will stop propagating to the calling exchange). I'll add an upgrade guide entry once the fix is confirmed by CI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gnodet
gnodet requested review from Croway and oscerd July 22, 2026 16:16
…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>
@gnodet

gnodet commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

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 AiToolExecutor (commits 13446c0 and onward). The producer now discovers tools from AiToolRegistry and delegates execution through AiToolExecutor.execute(), with exchange isolation applied in the addToolToResult() path.

RuntimeCamelException instead of plain RuntimeException ✅

Changed to RuntimeCamelException.wrapRuntimeCamelException(error.cause()) — the Camel idiom. This ensures ToolExecutionErrorHandler.handle(Throwable, ToolErrorContext) receives the real cause directly without needing to unwrap.

Exchange isolation test actually tests what it claims ✅

toolExecutionShouldNotLeakStateIntoCallingExchange now asserts:

  • Original headers survive tool execution
  • Tool argument headers (input) do NOT leak into the calling exchange
  • Tool route side-effect headers (toolSideEffect) do NOT leak

Added a companion test failingToolExecutionShouldNotLeakStateIntoCallingExchange for the failure path, confirming isolation is unconditional.

Upgrade guide entry ✅

Added a new section "Exchange isolation for Camel route tools" to the 4.22 upgrade guide (commit fba72d6) documenting:

  • Tool headers (arguments, side-effects, CamelToolName) no longer leak into the calling exchange
  • Tool execution errors are rethrown as RuntimeCamelException
  • Migration guidance for code that reads tool-related headers from the calling exchange

@github-actions github-actions Bot added the docs label Jul 22, 2026
@gnodet gnodet added the bug Something isn't working label Jul 23, 2026
@gnodet gnodet self-assigned this Jul 23, 2026
@gnodet gnodet added this to the 4.22.0 milestone Jul 23, 2026
@gnodet
gnodet merged commit 84e86ea into apache:main Jul 23, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants