Skip to content

CAMEL-23393: Enforce agentic loop token budget and expose cumulative …#24902

Open
atiaomar1978-hub wants to merge 4 commits into
apache:mainfrom
atiaomar1978-hub:CAMEL-23393-agentic-token-budget
Open

CAMEL-23393: Enforce agentic loop token budget and expose cumulative …#24902
atiaomar1978-hub wants to merge 4 commits into
apache:mainfrom
atiaomar1978-hub:CAMEL-23393-agentic-token-budget

Conversation

@atiaomar1978-hub

Copy link
Copy Markdown
Contributor

CAMEL-23393 — Agentic loop token budget

Issue: https://issues.apache.org/jira/browse/CAMEL-23393
Branch: CAMEL-23393-agentic-token-budget
Commit: cad1ca3d939

Problem

The MCP agentic loop in OpenAIProducer.processNonStreamingAgentic() was only capped by maxToolIterations (default 50). A loop could still burn through hundreds of thousands of tokens as the full conversation grew on each iteration.

Solution

Addition Default Behavior
maxAgenticTokens 0 (unlimited) Stops the loop when cumulative prompt + completion tokens exceed the budget
CamelOpenAIAgenticPromptTokens Cumulative prompt tokens across all agentic iterations
CamelOpenAIAgenticCompletionTokens Cumulative completion tokens across all agentic iterations
CamelOpenAIAgenticTotalTokens Cumulative total across all agentic iterations

Headers are set whenever usage data is available, even if no budget is configured.

When the budget is exceeded, an IllegalStateException is thrown (similar to maxToolIterations) with the iteration number and cumulative token counts.

Implementation

  • OpenAIAgenticTokenTracker — accumulates usage from each ChatCompletion in the loop
  • OpenAIProducer — tracks usage after each API call, sets headers, enforces budget
  • OpenAI mock — chat responses include usage; withUsage(prompt, completion) added for tests

Tests (97/97)

  • OpenAIAgenticTokenTrackerTest — unit test for token accumulation
  • OpenAIAgenticTokenBudgetTest — cumulative headers on successful loop; budget exceeded throws with descriptive message

…usage

Add maxAgenticTokens to cap cumulative prompt plus completion tokens across
MCP agentic iterations, expose cumulative usage headers, and extend the OpenAI
mock to return configurable usage metadata for tests.

@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-23393: Enforce agentic loop token budget and expose cumulative token usage

Verdict: APPROVE

Addresses a real operational concern — the MCP agentic loop was only capped by maxToolIterations (default 50), but each iteration could consume significant tokens, leading to unexpectedly large costs without any token-level guardrail.

What's good

  • Clean tracker design: OpenAIAgenticTokenTracker is a focused accumulator — final class, null-safe addUsage(ChatCompletion) that handles optional usage(), and computed getTotalTokens(). Using long for the counters is appropriate given that cumulative token counts across many iterations could exceed int range.

  • Correct enforcement timing: The budget check runs after each API call (tokenTracker.addUsagesetAgenticTokenHeadersenforceAgenticTokenBudget). This is the right order — you can't know the token count until after the call completes. The exception prevents further iterations while allowing the response from the budget-exceeding call to be available on the exchange (the headers are set before the exception).

  • Observability regardless of budget: The three cumulative headers (CamelOpenAIAgenticPromptTokens, CamelOpenAIAgenticCompletionTokens, CamelOpenAIAgenticTotalTokens) are set on every agentic loop iteration whether or not a budget is configured. This enables monitoring and dashboarding without requiring a budget.

  • Consistent error pattern: IllegalStateException with a descriptive message matches the existing maxToolIterations enforcement pattern. The message includes the budget, iteration number, and per-category token breakdown — very useful for debugging.

  • Backward-compatible mock changes: The ResponseBuilder updates maintain all existing method signatures and delegate to new overloads with defaults (DEFAULT_PROMPT_TOKENS = 10, DEFAULT_COMPLETION_TOKENS = 5). Existing tests are unaffected.

  • Good test coverage: The unit test verifies accumulation, and the two integration tests cover both the observability path (headers set on success) and the enforcement path (exception with correct message when budget exceeded).

Well-implemented feature — clean, focused, and consistent with existing patterns. 👏

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

@davsclaus

Copy link
Copy Markdown
Contributor

@atiaomar1978-hub would you mind leaving a Co-Authored-By in the commit message when you use AI coding assitance

@atiaomar1978-hub

Copy link
Copy Markdown
Contributor Author

@davsclaus sure.
I’ve been doing that, but Cursor removed the Co-authored-by line. I’ll make sure it’s included next time.

atiaomar1978-hub and others added 2 commits July 19, 2026 07:19
Commit generated catalog, component schema, and endpoint DSL artifacts
for maxAgenticTokens so CI clean-tree check passes.

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

@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 (3rd commit 4dc63fea): Regenerated OpenAI metadata for agentic token budget.

Standard generated metadata update — catalog JSON (openai.json), component JSON, and Endpoint DSL builder factory. Changes add the new token budget headers and options from the prior functional commit.

Approval stands. ✅

Claude Code on behalf of gnodet — AI-generated review

Add OpenAIAgenticPromptTokens, OpenAIAgenticCompletionTokens, and
OpenAIAgenticTotalTokens header accessors to the generated endpoint DSL
so CI clean-tree check passes.

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

@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 (4th commit 02e2532d): Regenerate endpoint DSL headers for agentic token usage.

Standard generated code — adds openAIAgenticPromptTokens(), openAIAgenticCompletionTokens(), and openAIAgenticTotalTokens() header constants to the Endpoint DSL builder. Consistent with the token budget feature from the prior commits.

Approval stands. ✅

Claude Code on behalf of gnodet — AI-generated review

@atiaomar1978-hub

Copy link
Copy Markdown
Contributor Author

@davsclaus would you please trigger the CI build ?

@github-actions

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • catalog/camel-catalog
  • components/camel-ai/camel-openai
  • dsl/camel-endpointdsl
  • test-infra/camel-test-infra-openai-mock

🔬 Scalpel shadow comparison — Scalpel: 15 tested, 27 compile-only — current: 13 all tested

Maveniverse Scalpel detected 42 affected modules (current approach: 13).

⚠️ 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 15 modules (4 direct + 11 downstream), skip tests for 27 (generated code, meta-modules)

Modules Scalpel would test (15)
  • 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-agent
  • camel-langchain4j-tools
  • camel-launcher-container
  • camel-openai
  • camel-test-infra-all
  • camel-test-infra-openai-mock
  • 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 (42 modules)
  • Camel :: AI :: LangChain4j :: Agent
  • Camel :: AI :: LangChain4j :: Tools
  • 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 :: Test Infra :: All test services
  • Camel :: Test Infra :: OpenAI Mock
  • 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

@atiaomar1978-hub

Copy link
Copy Markdown
Contributor Author

I will resolve conflicts after the merge of CAMEL-23394: Limit OpenAI conversation history size
#24901

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

Good addition — a cost ceiling on the agentic loop is a real operational need, and this is implemented cleanly. A couple of things I checked specifically that came out well:

  • The tracker is correctly scoped. OpenAIAgenticTokenTracker is a local variable inside processNonStreamingAgentic (OpenAIProducer.java:484), not a field on the Producer or Endpoint, so a fresh instance exists per invocation and it is thread-confined to the calling thread. No token-count leakage between concurrent exchanges — which is the failure mode I was most worried about. It is also package-private, so no new public API surface.
  • It genuinely enforces, rather than only observing: enforceAgenticTokenBudget (OpenAIProducer.java:620-630) throws IllegalStateException with a descriptive message, mirroring the existing maxToolIterations behaviour.
  • Headers follow the convention. The three new constants carry @Metadata in OpenAIConstants, use the Camel + component + feature PascalCase form per design/headers.adoc, and are therefore covered by DefaultHeaderFilterStrategy.
  • Off by default (0), so no upgrade-guide entry is required. Generated files are all present and correct.

Two things I would like you to consider before this merges.

A successful final answer that crosses the budget is thrown away

enforceAgenticTokenBudget is called at OpenAIProducer.java:491, which is before requireFirstChoice at :493 and before the final-response branch at :495. So when the model returns a normal stop response with no tool calls, and that response happens to push cumulative usage over the budget, the producer throws instead of returning the answer.

The concrete case: maxAgenticTokens=1000, MCP tools configured, and the model answers directly on the first call using 1200 tokens. No agentic looping happened at all, the tokens are already spent and unrecoverable, a complete valid answer is sitting in choice — and the route fails with no body. The budget exists to prevent further spend, and after a final response there is no further spend to prevent, so throwing there costs the user their answer without saving anything.

Moving the check to after the final-response early-return, or gating it on isToolCallsFinishReason(choice), would keep the protection while not discarding work already paid for.

Enforcement is post-call, and the option description reads like a hard cap

The check runs after create() at :488, which is the pragmatic choice — you cannot know a call's token cost without a tokenizer. But it means real spend can exceed the configured budget by up to one full call, and since the prompt grows each iteration, that last call is typically the most expensive one. The description ("Maximum cumulative prompt plus completion tokens allowed across the MCP agentic loop") does not convey that overshoot. Worth a sentence.

Related, and the one project-rule gap I found: the PR touches no .adoc file. components/camel-ai/camel-openai/src/main/docs/openai-mcp.adoc:188 documents the sibling option in exactly the place this one belongs:

The maxToolIterations option (default: 50) prevents infinite loops. If exceeded, an IllegalStateException is thrown.

maxAgenticTokens is the direct analogue and should sit on the adjacent line, covering the default, the post-call overshoot, and how the cumulative headers differ from the existing per-call ones. The generated option/header tables will populate automatically via the included partials, so this is narrative documentation only — but CLAUDE.md does ask for doc updates where applicable, and this is applicable.

Smaller notes

  • Confusable header pairs, with inconsistent declared types. In the agentic path both sets land on the same message: setResponseHeaders sets CamelOpenAIPromptTokens / CompletionTokens / TotalTokens (last call only) while setAgenticTokenHeaders sets the ...Agentic... variants (cumulative). The pre-existing three are declared javaType = "Integer" at OpenAIConstants.java:71-76, the new three "Long", though both are populated from the same long-returning SDK accessor. Your new declarations are the correct ones — the old ones are wrong. Not caused by this PR, but it puts the inconsistency in adjacent catalog rows, so it may be worth fixing opportunistically or filing a follow-up.
  • Test-infra mock now emits usage unconditionally. ResponseBuilder.createBaseChatCompletion always adds a usage block (10/5). Signatures are preserved, but the emitted JSON changed for every consumer, so existing mock-backed tests now get CamelOpenAIPromptTokens etc. populated where they previously were absent. Nothing in-repo asserts their absence and CI is green, but camel-test-infra-openai-mock is a published artifact used by camel-langchain4j-agent and camel-langchain4j-tools, so downstream repos could see the change. An opt-in emission, or a brief note, would be safer.
  • Test coverage gaps. The budget test only covers "first response already over budget". Missing: accumulation crossing the budget across multiple iterations (the actual scenario in the JIRA), the boundary case where total == maxAgenticTokens must not throw, and an assertion that the loop actually stopped issuing requests.
  • Negative values silently disable the budget (:623 guards <= 0 while the docs say "When 0"). Cosmetic, and consistent with maxToolIterations.
  • Scope: only processNonStreamingAgentic honours the budget; the simple and streaming paths ignore it. The description does say "across the MCP agentic loop", so it is stated — just worth being deliberate about.

None of this is a correctness bug, so I am leaving it as a comment rather than blocking. The final-answer case is the one I would most like to see addressed.


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.

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