Skip to content

CAMEL-23394: Limit OpenAI conversation history size#24901

Open
atiaomar1978-hub wants to merge 7 commits into
apache:mainfrom
atiaomar1978-hub:CAMEL-23394-conversation-history-size
Open

CAMEL-23394: Limit OpenAI conversation history size#24901
atiaomar1978-hub wants to merge 7 commits into
apache:mainfrom
atiaomar1978-hub:CAMEL-23394-conversation-history-size

Conversation

@atiaomar1978-hub

Copy link
Copy Markdown
Contributor

CAMEL-23394 — Conversation history size limits

Issue: https://issues.apache.org/jira/browse/CAMEL-23394
Branch: CAMEL-23394-conversation-history-size
Commit: f8e2ab40b40

Problem

With conversationMemory=true, history in CamelOpenAIConversationHistory grew without bound. Long or agentic exchanges could exceed the model context window and cause API errors.

Solution

Two new options on camel-openai:

Option Default Behavior
maxHistoryMessages 0 Keep only the N most recent history messages (0 = unlimited)
maxHistoryTokens 0 Drop oldest messages until estimated tokens (chars ÷ 4) fit the limit (0 = unlimited)

System/developer messages are not stored in history (they’re prepended in buildMessages()), so they’re outside these limits.

Implementation

  • OpenAIConversationHistoryTrimmer — sliding-window trim + DEBUG log when messages are dropped
  • Applied when reading history (addConversationHistory) and writing it (OpenAIProducer, OpenAIToolExecutionProducer)
  • Regenerated openai.json + endpoint configurer/uri factory

Tests (100/100)

  • OpenAIConversationHistoryTrimmerTest — 5 unit tests (message cap, token cap, both, tool-call args in token estimate, no-op when unset)
  • OpenAIConversationHistorySizeTest — mock multi-turn route; maxHistoryMessages=2 drops old turns from requests and exchange property

Add maxHistoryMessages and maxHistoryTokens options to trim exchange-scoped
conversation history before it exceeds model context limits.

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

Claude Code review on behalf of gnodet — AI-generated review

CAMEL-23394: Limit OpenAI conversation history size

Verdict: APPROVE

Addresses a real problem — with conversationMemory=true, the conversation history in CamelOpenAIConversationHistory grows without bound, eventually exceeding the model's context window and causing API errors.

What's good

  • Clean design: OpenAIConversationHistoryTrimmer is a well-structured utility — final class, private constructor, static methods, DEBUG logging when messages are dropped. Token estimation using chars ÷ 4 is a reasonable and widely-used heuristic.

  • Correct trim ordering: message limit applied first (O(1) sublist), then token limit loop. This minimizes the work for token estimation since the list is already smaller.

  • Comprehensive token estimation: counts text content, tool-call function names, tool-call arguments, tool-result content, and multi-part content arrays. The ceiling division (chars + CHARS_PER_TOKEN - 1) / CHARS_PER_TOKEN is correct.

  • Dual integration points: trimming applied both when reading history (in addConversationHistory) and when writing it (in all three updateConversationHistory overloads + OpenAIToolExecutionProducer). The exchange property is kept in sync, so subsequent calls in the same route see the trimmed state.

  • Strong test coverage: 5 unit tests on the trimmer covering no-limit passthrough, message cap, token cap, combined limits, and tool-call argument counting. The integration test (OpenAIConversationHistorySizeTest) verifies end-to-end with a 4-turn mock conversation, asserting both the HTTP request payload and the exchange property.

Non-blocking observations

  1. Tool-call / tool-result pair splitting: the sliding window drops oldest messages without regard to message-type boundaries. With aggressive limits (e.g., maxHistoryMessages=2 in a tool-using conversation), a tool-result message could be retained without the corresponding assistant tool-call message, which the OpenAI API rejects with an error about missing tool_call_id references. This is an edge case users would need to configure around, and adding boundary awareness would significantly complicate the trimmer — fine for a follow-up if needed.

  2. ArrayList.remove(0) in a loop: the token-trimming while loop calls trimmed.remove(0) which is O(n) per removal, making the worst case O(n²). For practical conversation history sizes (dozens to low hundreds of messages) this is negligible, but a LinkedList or tracking a start index would make it O(n) if this ever matters.

  3. Image content parts: multi-modal user messages with image parts contribute 0 to the token estimate (only text parts are counted). This means the estimate will undercount for vision-enabled conversations. Acceptable since image token costs are model-dependent and hard to estimate without API metadata.

Well-implemented feature with good test coverage. Nice work! 👏

…imate

Drop oldest whole segments to keep assistant tool-call blocks paired with tool
results, use O(n) segment-based token trimming, and count image payload size in
the token heuristic.
@atiaomar1978-hub

Copy link
Copy Markdown
Contributor Author

All three review points are addressed in commit 84b52e74442 (pushed).

1. Tool-call / tool-result pairing

Trimmer now works on segments:

  • Assistant message with tool_calls + following tool messages = one atomic segment
  • Oldest segments are dropped, never a lone tool message without its assistant block
  • If keeping the latest N messages would orphan a tool result, the whole tool block is dropped instead (keeps only the trailing assistant reply)

New tests: trimShouldKeepAssistantToolCallBlockIntactWhenMessageLimitWouldSplitIt, trimShouldRetainAssistantAndToolResultsAsOneSegment

2. O(n²) remove(0) loop

Token trimming now advances a segment index and uses subList once — O(segments), not repeated remove(0).

3. Image token estimate

Multi-modal user messages now count image URL/base64 payload length (plus text parts) in the chars÷4 heuristic.

New test: estimateTokensShouldIncludeImagePayloadSize

Docs

@Metadata / openai.json updated to describe segment-based trimming and image payload estimation.

Tests: 103/103 passed

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

Claude Code review on behalf of gnodet — AI-generated review

Re-review: 2nd commit (84b52e74) — Segment-based trimming + improved token estimate

Great follow-up that addresses all three observations from my initial review:

  1. Tool-call / tool-result pair splitting → fixed: The new Segment abstraction groups assistant tool-call messages with their subsequent tool-result messages, so trimming operates on segments rather than individual messages. buildSegments() correctly scans for assistant-with-tool-calls followed by consecutive tool messages. findFirstSegmentForMessageLimit() walks backward from the most recent segment, accumulating counts — clean approach. The description now documents that "slightly more than maxHistoryMessages may be retained to preserve tool result pairing."

  2. O(n²) ArrayList.remove(0) → fixed: Token-limit trimming now scans forward with findFirstSegmentForTokenLimit() using index-based estimateTokens(history, from, to) instead of head-removal. No more repeated shifting.

  3. Image content parts → fixed: estimateContentPartChars() now counts both text() length and imageUrl().url() length (which includes base64 data URIs). Good improvement for multi-modal conversations.

The three new tests cover the key scenarios well — segment integrity with message limits, segment retention, and image payload in token estimates. The existing tool-call test correctly updated to assertThat(trimmed).isEmpty() since the safety check now clears the list when a single oversized segment remains.

Original APPROVE stands — these changes strengthen the PR. 👍

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

@atiaomar1978-hub

Copy link
Copy Markdown
Contributor Author

@davsclaus I'm working on the issue.

Commit generated catalog, component schema, and endpoint DSL artifacts
for maxHistoryMessages and maxHistoryTokens so CI clean-tree check passes.

Co-Authored-By: Cursor <cursoragent@cursor.com>
@atiaomar1978-hub

Copy link
Copy Markdown
Contributor Author

Tests passed.

Module: components/camel-ai/camel-openai
Tests run: 9 — all passed

Test class Tests Result
OpenAIConversationHistorySizeTest 1 Passed
OpenAIConversationHistoryTrimmerTest 8 Passed

BUILD SUCCESS (~1 min)

@gnodet gnodet 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 4th commit (5110d97): Generated metadata regeneration for history size options — standard regeneration of catalog JSON and Endpoint DSL builder. My earlier approval stands.

Claude Code on behalf of gnodet — AI-generated review

@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • catalog/camel-catalog
  • components/camel-ai/camel-openai
  • dsl/camel-endpointdsl

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

Modules Scalpel would test (11)
  • 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-launcher-container
  • camel-openai
  • camel-yaml-dsl-validator
  • camel-yaml-dsl-validator-maven-plugin
Modules with tests skipped (27)
  • 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
  • docs
  • 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-openai: 6 test(s) disabled on GitHub Actions
All tested modules (38 modules)
  • Camel :: AI :: OpenAI
  • 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

@davsclaus

Copy link
Copy Markdown
Contributor

merge conflicts

@atiaomar1978-hub

Copy link
Copy Markdown
Contributor Author

Should I resolve it ? @davsclaus

@davsclaus

Copy link
Copy Markdown
Contributor

yes

@atiaomar1978-hub

Copy link
Copy Markdown
Contributor Author

@davsclaus Done , could you please trigger the build to make sure all good ?

@atiaomar1978-hub

Copy link
Copy Markdown
Contributor Author

camel-openai test results (C:\c\camel-23394-fix @ a13b6533c0e8)

Tests run: 111, Failures: 0, Errors: 0, Skipped: 0
Test class Tests Failures Errors Skipped
McpToolConverterTest 7 0 0 0
OpenAIAgenticLoopMalformedToolArgumentsTest 1 0 0 0
OpenAIAudioTranscriptionMockTest 7 0 0 0
OpenAIAudioTranscriptionVerboseMockTest 1 0 0 0
OpenAIClientConfigurationTest 8 0 0 0
OpenAIConversationHistorySizeTest 1 0 0 0
OpenAIConversationHistoryTrimmerTest 8 0 0 0
OpenAIConversationMemoryResetTest 1 0 0 0
OpenAIConversationMemoryUserMessageTest 1 0 0 0
OpenAIEmbeddingsMockTest 6 0 0 0
OpenAIEmptyChoicesResponseTest 2 0 0 0
OpenAIEndpointMcpReconnectConcurrencyTest 3 0 0 0
OpenAIMtlsMockTest 5 0 0 0
OpenAIOutputClassTest 2 0 0 0
OpenAIProducerMcpMockTest 10 0 0 0
OpenAIProducerMockTest 10 0 0 0
OpenAISslConfigurationTest 15 0 0 0
OpenAISslContextParametersTest 7 0 0 0
OpenAISslMockTest 5 0 0 0
OpenAIVisionBodyTypesMockTest 11 0 0 0

OpenAIClientConfigurationTest includes the requestTimeout coverage (URI parsing, default 0, client timeout application).

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

Thanks for this — bounded conversation memory is a genuinely useful addition, and the segment-based rework in the second commit is the right design. I want to call out specifically that it gets the hard part right: buildSegments groups an assistant-with-tool_calls together with its following tool replies, and trimming removes only whole segments from the front, so an orphaned tool message can never be exposed to the API. The system/developer messages are also safe, since they are built fresh into messages before the history is appended and never live in the history property. Those are exactly the two ways this feature usually breaks, and both are handled.

Unfortunately I think there is a reachable runtime failure that needs fixing first.

Blocking: trim() can return an immutable list that later gets mutated

OpenAIConversationHistoryTrimmer.java:70 and :76 both assign trimmed = List.of(). That immutable instance is then stored into the exchange property at OpenAIProducer.java:282, :734, :768, :802 and in OpenAIToolExecutionProducer.

The problem is that every reader mutates the stored list in place. AbstractExchange.getProperty hands back the same instance with no defensive copy — core/camel-support/src/main/java/org/apache/camel/support/AbstractExchange.java:207-209 short-circuits on type.isInstance(value), and List.class.isInstance(List.of()) is true. So on the next call updateConversationHistory reads it, passes the null guard, and hits OpenAIProducer.java:703 (history.add(pendingUserMessage)) or :733 (history.add(assistantMessage)) → UnsupportedOperationException. The same applies to the history.addAll(...) at :757 and :792.

The path to List.of() is not exotic. findFirstSegmentForTokenLimit returns segments.size() when even the final segment alone exceeds the budget, which lands on :70. So conversationMemory=true&maxHistoryTokens=1000 plus one ~5 KB tool result (≈1250 estimated tokens) is enough, on any route that makes a second call.

The fix is one character each: new ArrayList<>() instead of List.of() at :70 and :76. It would also be worth returning a mutable empty list rather than the caller's list at :46, so trim never hands back something the caller does not own.

I verified this by reading the sources at PR head a13b6533, not by executing it — but the ownership chain is unambiguous, and I think a route test would confirm it immediately (see below).

Worth reconsidering: an oversized segment wipes the whole history

The two limits behave asymmetrically, and I do not think the token side is what you want:

  • findFirstSegmentForMessageLimit walks backwards from the last segment, so the final segment is always retained even if it alone exceeds maxHistoryMessages. It over-retains, which the option description already discloses ("may retain slightly more than this limit"). Good.
  • findFirstSegmentForTokenLimit has no such floor. If the last segment alone is over budget it returns segments.size() and everything is dropped, reported only at DEBUG.

That is a silent, total loss of conversation memory triggered by a single large tool result or a base64 image. And it does not even achieve the goal in that case, because the trimmer only bounds history — the oversized current user message is added separately, so the request still overflows the context window. Matching the message-limit semantics (always keep the last segment) and logging at WARN when the limit is unsatisfiable would be more predictable.

Smaller points

  • No route-level test for maxHistoryTokens. OpenAIConversationHistorySizeTest covers only maxHistoryMessages=2. A test with a small maxHistoryTokens and two .to("openai:...") hops is what would have surfaced the blocker above, so it is worth adding regardless.
  • int char accumulator can overflow. The character total accumulates into an int, and base64 image data-URIs are counted in full. Past ~2 GB of accumulated characters it goes negative, the estimate goes negative, and trimming silently switches off — precisely in the pathological case the feature exists for. A long accumulator with a clamp would close it.
  • :75-77 looks like dead code. When the token path picks firstSegment, the suffix is already guaranteed within budget; when Math.max picks the message-path index instead, the retained set is a subset of one already within budget. I could not construct an input that reaches the inner List.of(). Harmless, but it reads as a safety net that never fires.
  • Docs prose. components/camel-ai/camel-openai/src/main/docs/openai-component.adoc still describes conversation history as unbounded in its "Conversation Memory" section. The options table picks the new options up automatically via the included partial, so this is narrative only.

Checks that passed

Generated files are complete and correct (module openai.json, catalog JSON, OpenAIEndpointConfigurer, OpenAIEndpointUriFactory, endpoint-dsl). Both options carry @Metadata(description=...). Default 0 reproduces the previous behaviour exactly, and negative values are treated as unlimited, so no upgrade-guide entry is needed. No Thread.sleep in the tests, no FQCNs, commit format conforms, and CI is green.

Only the immutable-list issue is blocking from my side — the rest is discussion. Thanks again for the work here, the segment model is a solid foundation.


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.

Return mutable lists from OpenAIConversationHistoryTrimmer, always retain the last segment for token limits, add route/unit tests, and update docs.

Co-Authored-By: Cursor <cursoragent@cursor.com>
@atiaomar1978-hub

Copy link
Copy Markdown
Contributor Author

Applied all review fixes from @oscerd on PR #24901 for CAMEL-23394. Summary:

Blocking fix — immutable list

OpenAIConversationHistoryTrimmer.trim() no longer returns List.of(). It now returns mutable ArrayList instances so OpenAIProducer / OpenAIToolExecutionProducer can safely call history.add(...) on later route hops without UnsupportedOperationException.

Other review items addressed

Item Fix
Token-limit asymmetry findFirstSegmentForTokenLimit always keeps the last segment (matches message-limit semantics) + WARN when that segment alone exceeds the budget
Dead code Removed the inner List.of() safety check (lines 75–77)
int overflow Char accumulator uses long with clamp to Integer.MAX_VALUE
Route test for maxHistoryTokens Added OpenAIConversationHistoryTokenLimitTest (4-turn mock route)
Mutable list unit tests Added 3 tests in OpenAIConversationHistoryTrimmerTest
Docs Updated openai-component.adoc Conversation Memory section
Metadata Updated @Metadata on maxHistoryTokens + regenerated openai.json / catalog JSON

Test results

Full camel-openai unit suite in C:\c\camel-23394-fix:

Tests run: 115, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

New/updated history tests: 13 total (was 9 before).

Files changed

  • OpenAIConversationHistoryTrimmer.java — core fixes
  • OpenAIConfiguration.java — metadata update
  • OpenAIConversationHistoryTrimmerTest.java — 3 new unit tests
  • OpenAIConversationHistoryTokenLimitTest.java — new route-level test
  • openai-component.adoc — docs
  • openai.json + catalog openai.json — generated metadata

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

New commit 5b4f03e68964 reviewed — addresses review feedback with three meaningful fixes plus good test coverage.

Key improvements:

  1. Mutable list contractList.of()new ArrayList<>() and history.subList(...)new ArrayList<>(history.subList(...)). Essential fix: callers add subsequent messages to the trimmed list, so immutability would cause UnsupportedOperationException at runtime.

  2. Always retain the most recent segment — Previously, if the last segment alone exceeded maxHistoryTokens, the trimmer returned an empty list, effectively wiping the entire conversation. Now findFirstSegmentForTokenLimit short-circuits with a WARN log and keeps the most recent segment. This is the right trade-off: a slightly oversized history is far better than a completely amnesia'd conversation.

  3. Integer overflow protectionchars accumulator changed from int to long in estimateTokens, with a Integer.MAX_VALUE cap in tokensFromChars. Prevents silent wraparound with large base64-encoded image payloads.

Tests are thorough:

  • New integration test (OpenAIConversationHistoryTokenLimitTest) with mock server verifying 4-turn trimming end-to-end
  • Unit tests for mutability, empty-list mutability, and the "last segment exceeds limit" edge case
  • Existing test assertion correctly updated to reflect the new "always keep last" semantics

Clean, well-structured commit. LGTM.

Claude Code on behalf of gnodet — AI-generated review

@atiaomar1978-hub
atiaomar1978-hub requested a review from oscerd July 20, 2026 15:12
@atiaomar1978-hub

Copy link
Copy Markdown
Contributor Author

@davsclaus could you please trigger the CI build and tests ?

@github-actions github-actions Bot added the docs label Jul 20, 2026
Sync generated Endpoint DSL javadoc with OpenAIConfiguration metadata so
CI sourcecheck passes after the maxHistoryTokens description update.

Co-authored-by: Cursor <cursoragent@cursor.com>
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