Skip to content

CAMEL-23943: Fix three defects in camel-langchain4j-tools producer - #24991

Merged
gnodet merged 3 commits into
apache:mainfrom
gnodet:fix-camel-23943-camel-langchain4j-tools-has-three
Jul 23, 2026
Merged

CAMEL-23943: Fix three defects in camel-langchain4j-tools producer#24991
gnodet merged 3 commits into
apache:mainfrom
gnodet:fix-camel-23943-camel-langchain4j-tools-has-three

Conversation

@gnodet

@gnodet gnodet commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes three defects in the camel-langchain4j-tools producer as reported in CAMEL-23943:

  1. Unbounded tool-calling loop — The do/while(true) loop in toolsChat() has no iteration cap. If the LLM keeps requesting tools indefinitely, it never terminates. Fix: Added maxToolCallingRoundTrips endpoint option (default 10, producer-only) that bounds the loop and throws RuntimeCamelException when exceeded.

  2. Crash on hallucinated tool names — When the LLM hallucinates a tool name, .findFirst().get() throws NoSuchElementException. Fix: Changed to .orElse(null) with a graceful error message sent back to the LLM listing available tools.

  3. Tool errors swallowed silently — When a tool route throws an exception, the error is set on toolExchange but never propagated to the LLM; subsequent ExchangeHelper.copyResults overwrites exchange state. Fix: Errors are now sent back to the LLM as ToolExecutionResultMessage, moved copyResults inside the try block.

Changes

  • LangChain4jToolsProducer.java — All three fixes in toolsChat()/invokeTools(), with null-safe e.getMessage() handling
  • LangChain4jToolsEndpoint.java — New maxToolCallingRoundTrips @UriParam (producer-only, default 10), with validation rejecting negative values
  • LangChain4jToolDefectsTest.java — Three targeted reproducer tests (one per defect), verified to fail before fix and pass after
  • camel-4x-upgrade-guide-4_22.adoc — Documents all three behavior changes
  • Generated files: component JSON, catalog JSON, endpoint DSL factory

Test plan

  • All 3 tests fail on original code (verified by reverting producer to parent commit)
  • All 3 tests pass with fix applied
  • Full module test suite passes (42 tests, 0 failures)
  • CI green

Review feedback addressed

  • Added upgrade guide entry (requested by @davsclaus, @oscerd, self-review)
  • Fixed e.getMessage() null check — uses class name as fallback (flagged by self-review, @oscerd)
  • Reject negative maxToolCallingRoundTrips with IllegalArgumentException (flagged by @oscerd)
  • Acknowledged exposed=false + ToolSearchTool dead path as pre-existing issue (flagged by @oscerd) — will create JIRA follow-up

🤖 Generated with Claude Code on behalf of gnodet

1. Unbounded tool-calling loop: the do/while(true) loop in toolsChat
   had no iteration cap, so a misbehaving LLM could loop forever.
   Added maxToolCallingRoundTrips endpoint option (default 10) that
   throws RuntimeCamelException when exceeded.

2. Crash on hallucinated tool names: .findFirst().get() threw
   NoSuchElementException when the LLM requested a non-existent tool.
   Changed to .orElse(null) and sends an error message back to the LLM
   listing available tools, allowing graceful recovery.

3. Tool errors swallowed silently: the catch block set the exception on
   toolExchange but never propagated it to the LLM, and subsequent
   logic overwrote the exchange state. Now catches both thrown exceptions
   and processor-set exceptions, sends error details back to the LLM as
   a ToolExecutionResultMessage, and does not pollute the main exchange.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@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.

- Regenerated catalog JSON and endpoint DSL factory for the new
  maxToolCallingRoundTrips option (fixes CI "uncommitted changes" check)
- Improved defect 1 test to use a custom mock that always returns tool
  calls, creating a true infinite loop scenario. Added @timeout safety
  net so the test fails cleanly (timeout) if the fix is reverted, instead
  of hanging forever.
- Verified all three tests fail before the fix and pass after:
  - Test 1: times out (infinite loop) → RuntimeCamelException (fix works)
  - Test 2: NoSuchElementException → graceful recovery
  - Test 3: exception on exchange → error sent to LLM

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • catalog/camel-catalog
  • components/camel-ai/camel-langchain4j-tools
  • docs
  • dsl/camel-endpointdsl

🔬 Scalpel shadow comparison — Scalpel: 12 tested, 26 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 12 modules (4 direct + 8 downstream), skip tests for 26 (generated code, meta-modules)

Modules Scalpel would test (12)
  • camel-catalog
  • camel-endpointdsl
  • camel-jbang-mcp
  • camel-jbang-plugin-mcp
  • camel-jbang-plugin-route-parser
  • camel-jbang-plugin-tui
  • camel-jbang-plugin-validate
  • camel-langchain4j-tools
  • camel-launcher-container
  • camel-yaml-dsl-validator
  • camel-yaml-dsl-validator-maven-plugin
  • docs
Modules with tests skipped (26)
  • apache-camel
  • camel-allcomponents
  • camel-catalog-console
  • camel-catalog-lucene
  • camel-catalog-maven
  • camel-catalog-suggest
  • camel-componentdsl
  • camel-csimple-maven-plugin
  • 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-tools: 6 test(s) disabled on GitHub Actions
All tested modules (38 modules)
  • Camel :: AI :: LangChain4j :: Tools
  • 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

Focused, well-crafted fix for three genuine defects in the LangChain4jToolsProducer.toolsChat() loop. Each fix is minimal, targeted, and backed by a dedicated test. The changes are production-ready.

What I Love

  • The ExchangeHelper.copyResults relocation is subtle but important. Moving it inside the try block means a failed tool no longer pollutes the main exchange with a stale exception. In the original code, copyResults was called unconditionally after the catch block, which copied the exception from toolExchange to the main exchange — yet the loop continued anyway, leaving the exchange in an inconsistent state. This fix resolves that silently.

  • Sending errors back to the LLM as ToolExecutionResultMessage is the right pattern. This gives the LLM a chance to self-correct (e.g., try a different tool or answer directly) rather than crashing the entire interaction. The hallucinated-tool error message even lists available tools, which is a nice touch for LLM self-correction.

  • The test design using OpenAIMock with targeted scenarios is excellent. Each test reproduces exactly one defect, the @Timeout(10) on the infinite-loop test is a proper safety net, and the assertions verify both the positive (got a result) and negative (no exception) conditions.

Findings

Critical

None — nice work!

Important

None.

Suggestions & Nits

  1. [Suggestion] Upgrade guide entry for maxToolCallingRoundTrips default. The previous behavior was effectively unlimited round trips (the do/while(true) ran forever). With the new default of 10, any existing setup where the LLM legitimately needs 11+ round trips before converging would now throw a RuntimeCamelException. While that scenario is unlikely — and was a latent bug anyway — an upgrade guide entry mentioning the new option and its default would help users who encounter the exception after upgrading. Something along the lines of:

    The langchain4j-tools producer now limits tool-calling round trips to 10 by default (previously unlimited). If your LLM interaction legitimately requires more iterations, set the maxToolCallingRoundTrips endpoint option to a higher value. Set to 0 for unlimited (not recommended).

  2. [Nit] e.getMessage() in the tool-error result could be null. If the caught exception has a null message (rare but possible, e.g., new RuntimeException()), the tool result sent to the LLM would read "Error executing tool 'ToolName': null". A minor defensive check would make this slightly cleaner:

    String errorDetail = e.getMessage() != null ? e.getMessage() : e.getClass().getName();
    toolResult = "Error executing tool '" + toolName + "': " + errorDetail;

    This is purely cosmetic — the current behavior is functional.

Overall

This is a thorough, well-reasoned fix. The three defects were real, the fixes are clean, and the test coverage is solid. The interaction between all three fixes is sound: hallucinated-tool errors and tool-execution errors feed back into the LLM gracefully, and the max round trips cap provides a safety net across all scenarios. The check for toolExchange.getException() after process() is particularly important — without it, Camel's throwException DSL (which sets the exception on the exchange rather than throwing it) would be silently swallowed.

Note: This review would be an APPROVE, but GitHub does not allow approving your own PR. Requesting review from relevant committers is recommended.

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

LGTM — all three fixes are correct, well-tested, and follow project conventions.

Summary

The PR fixes three genuine defects in LangChain4jToolsProducer:

  1. Unbounded tool-calling loop — bounded by new maxToolCallingRoundTrips option (default 10)
  2. Crash on hallucinated tool names.findFirst().get().orElse(null) with graceful error back to LLM
  3. Tool errors swallowed silently — errors now sent back to the LLM as ToolExecutionResultMessage

Conventions

  • ✅ Test class and methods are package-private
  • ✅ AssertJ assertions used throughout
  • ✅ No Thread.sleep()
  • ✅ Generated files regenerated
  • @Timeout safety net on infinite loop test

Suggestion

  • Upgrade guide entry: Adding maxToolCallingRoundTrips with default 10 changes behavior from unbounded to bounded loops. Existing routes exceeding 10 round trips would now throw RuntimeCamelException. Consider documenting this in camel-4x-upgrade-guide-4_22.adoc.

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

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

Reviewed all three fixes. Since this producer sits on the prompt-injection path, I want to lead with the security check: the declaredParams allowlist is preserved verbatim and is not weakened by this PR.

I verified that mechanically rather than by eye. The allowlist derivation and its guard are outside every hunk in the diff — hunk 2 ends at old line 222 and hunk 3 starts at old line 261, so the whole block survives untouched. On the PR head the guard still reads if (!allowedParams.contains(name)) with the "Skipping undeclared tool argument" warning, and the single call that turns a model-supplied name into a header still sits inside it. Both new code paths — the hallucinated-tool branch and the error branch — set zero headers. Good.

The round-trip cap and the hallucinated-tool handling both look right to me, and the test class is well-formed (AssertJ, @Timeout(10), no Thread.sleep, package-private).

The one thing I'd like addressed: tool failures no longer reach the route

This is the change I think deserves more attention than the round-trip default that earlier reviews focused on.

ExchangeHelper.copyResults moved inside the try, so it now runs only on the success path. The consequence chain:

  • ExchangeHelper.doCopyResults ends with result.setException(source.getException()) (core/camel-support/src/main/java/org/apache/camel/support/ExchangeHelper.java:400).
  • Before, copyResults was unconditional, so a tool failure propagated onto the caller's exchange and the route failed / onException fired.
  • After, on the error path the calling exchange is never touched. The producer returns normally, the route succeeds, and the failure is visible only as a string handed back to the LLM.

So a route author who wrapped langchain4j-tools: in onException(...) expecting to catch tool failures now gets silence.

I want to be fair about the starting point: the old behaviour was already unreliable, because a later successful tool's copyResults would call setException(null) and wipe the exception. So this isn't a clean regression — it replaces a flaky signal with no signal. And feeding the error back to the LLM so it can recover is a defensible design; it may well be what you intend.

But it is a user-visible behaviour change with no opt-out and no documentation, and I don't think users will discover it until a tool silently fails in production. At minimum it needs an upgrade-guide note; a failOnToolError-style switch would be better if you want to keep both audiences.

Missing upgrade-guide entry

Related: docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc has no === camel-langchain4j-tools section at all. Two things here are user-visible — the round-trip cap going from unbounded to 10 and now throwing, and the error-propagation change above. For calibration, CAMEL-23382 added a 37-line entry to that same file for a comparable change in the adjacent component.

Smaller points

  • Defect 2's fix closes the crash but the underlying feature stays dead. The exposed=false + tool-search combination cannot work by construction: CamelToolExecutorCache keeps tools and searchableTools in two disjoint maps, computeCandidates builds callableTools from the exposed map only, and ToolSearchTool searches the searchable map. So anything the search tool returns is guaranteed absent from callableTools. Previously that was a NoSuchElementException; now it's "Tool 'X' not found. Available tools: <exposed only>", which directly contradicts what the search tool just told the model — so the model will likely retry and burn round trips until the new cap throws. Better than crashing, but worth a follow-up ticket rather than leaving it looking fixed.
  • Negative values silently mean unlimited. The guard is maxRoundTrips > 0 while the description says "Set to 0 for unlimited", so -1 also disables the cap with no warning.
  • e.getMessage() can be null, giving "Error executing tool 'X': null" — you flagged this yourself in the self-review.
  • The option is endpoint-only; LangChain4jToolsConfiguration holds just chatModel, so it can't be set via camel.component.langchain4j-tools.*. The agent side routes the equivalent through AgentConfiguration. Not necessarily wrong, just an asymmetry.

CI is green and the branch is clean. Nothing here blocks from my side except the doc gap, and I'd genuinely like your read on whether the error-swallowing is intended.


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.

- Add upgrade guide entry documenting the three behavior changes:
  maxToolCallingRoundTrips default, hallucinated tool handling, and
  tool error propagation change
- Fix null e.getMessage() in tool error result sent to LLM
- Reject negative maxToolCallingRoundTrips values with
  IllegalArgumentException

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 security-focused review. All actionable items addressed in c82475d:

Addressed

  1. Upgrade guide entry — Added a === camel-langchain4j-tools section covering all three behavior changes: the round-trip cap, hallucinated tool handling, and the error propagation change. The error propagation section explicitly calls out that onException(...) will no longer see tool failures.

  2. e.getMessage() null check — Now uses e.getClass().getName() as fallback when message is null.

  3. Negative valuessetMaxToolCallingRoundTrips now throws IllegalArgumentException for negative values.

Acknowledged (not in scope for this PR)

  1. exposed=false + tool-search dead path — You're right that this is broken by construction. The fix in this PR (graceful error instead of crash) is strictly better than the NoSuchElementException, but the feature remains non-functional. I'll create a JIRA follow-up for this since it's a pre-existing architectural issue, not something introduced by this PR.

  2. Endpoint-only option — Valid observation about asymmetry with AgentConfiguration. This is consistent with how the existing endpoint options work in this component, but worth considering for a follow-up.

On the error propagation design

The previous behavior was indeed unreliable as you noted — a later successful tool's copyResults would wipe the exception, and the loop continued anyway. The new behavior (feed errors back to the LLM) is intentional: it follows the LangChain4j pattern where the LLM gets a chance to self-correct. The upgrade guide now documents this clearly, including the impact on onException(...) routes.

@gnodet
gnodet requested a review from oscerd July 22, 2026 12:46
@github-actions github-actions Bot added the docs label Jul 22, 2026
@gnodet
gnodet requested a review from davsclaus July 22, 2026 14:49
@gnodet gnodet added this to the 4.22.0 milestone Jul 23, 2026
@gnodet gnodet self-assigned this Jul 23, 2026
@gnodet gnodet added the bug Something isn't working label Jul 23, 2026
@gnodet
gnodet merged commit 7e10b35 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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants