This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 3d443a96cf08 CAMEL-23395: Add OpenAI agentic loop trace and lifecycle
events (#26148)
3d443a96cf08 is described below
commit 3d443a96cf081bd6865d1836c49c6f50f80ead4f
Author: Omar Atie <[email protected]>
AuthorDate: Mon Sep 7 23:05:36 2026 -0700
CAMEL-23395: Add OpenAI agentic loop trace and lifecycle events (#26148)
Adds per-iteration observability to the camel-openai MCP auto-tool agentic
loop. Every model call is recorded in a new CamelOpenAIAgenticTrace exchange
property holding AgenticIterationTrace / AgenticToolCallTrace records with
the
tool calls made, truncated arguments and results, per-iteration token usage
and
duration, keyed by a unique 1-based model call number.
Three Custom CamelEvents are emitted so EventNotifier listeners can follow
the
loop without inspecting the exchange: OpenAIAgenticLoopStartedEvent,
OpenAIAgenticToolCallExecutedEvent and OpenAIAgenticLoopCompletedEvent. They
deliberately do not implement ExchangeEvent, to avoid polluting generic
exchange lifecycle metrics.
The loop body is wrapped in try/finally so the trace and the completed event
are finalized on failure paths too, and the LLM round is recorded before the
token-budget abort throws. McpToolCallExecutor.ToolResult now carries
durationMs and success. @Metadata(skip = true) is honoured by
EndpointSchemaGeneratorMojo so the trace property stays out of the generated
catalog headers and is documented in openai-mcp.adoc instead.
Closes #26148
Co-authored-by: Cursor Agent <[email protected]>
---
.../org/apache/camel/catalog/docs/openai-mcp.adoc | 16 ++
.../camel-openai/src/main/docs/openai-mcp.adoc | 16 ++
.../openai/AbstractOpenAIExchangeEvent.java | 63 ++++++
...okenTracker.java => AgenticIterationTrace.java} | 46 ++--
...TokenTracker.java => AgenticToolCallTrace.java} | 46 ++--
.../component/openai/McpToolCallExecutor.java | 45 +++-
.../openai/OpenAIAgenticLoopCompletedEvent.java | 62 +++++
...ker.java => OpenAIAgenticLoopStartedEvent.java} | 43 ++--
.../openai/OpenAIAgenticObservability.java | 136 +++++++++++
.../openai/OpenAIAgenticTokenTracker.java | 15 ++
.../openai/OpenAIAgenticToolCallExecutedEvent.java | 69 ++++++
.../camel/component/openai/OpenAIConstants.java | 4 +-
.../camel/component/openai/OpenAIProducer.java | 207 ++++++++++-------
.../openai/OpenAIAgenticEventNotifierTest.java | 185 +++++++++++++++
...st.java => OpenAIAgenticObservabilityTest.java} | 28 +--
.../openai/OpenAIAgenticTokenTrackerTest.java | 12 +
.../component/openai/OpenAIAgenticTraceTest.java | 251 +++++++++++++++++++++
.../packaging/EndpointSchemaGeneratorMojo.java | 3 +
18 files changed, 1046 insertions(+), 201 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-mcp.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-mcp.adoc
index 5f970c739d6d..1f5b17c1828c 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-mcp.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-mcp.adoc
@@ -481,8 +481,24 @@ The following headers are set after the agentic loop
completes:
| `CamelOpenAIToolIterations` | Integer | Number of tool call iterations
performed
| `CamelOpenAIMcpToolCalls` | List<String> | Ordered list of tool names called
during the loop
| `CamelOpenAIMcpReturnDirect` | Boolean | `true` if the response came
directly from a tool with `returnDirect`
+| `CamelOpenAIAgenticPromptTokens` | Long | Cumulative prompt tokens across
all agentic loop iterations
+| `CamelOpenAIAgenticCompletionTokens` | Long | Cumulative completion tokens
across all agentic loop iterations
+| `CamelOpenAIAgenticTotalTokens` | Long | Cumulative total tokens across all
agentic loop iterations
|===
+The exchange property `CamelOpenAIAgenticTrace` holds a
`List<AgenticIterationTrace>` with per-iteration
+breakdown: iteration number, tool calls (name, truncated arguments and
results, duration, success flag),
+token usage for that model call, and iteration duration. Downstream processors
in the same route can inspect
+this property for debugging, auditing, or routing decisions without waiting
for external observability backends.
+
+Custom `CamelEvent` instances are also emitted during the agentic loop for
EventNotifier listeners:
+
+* `OpenAIAgenticLoopStartedEvent` — tool count and max iterations
+* `OpenAIAgenticToolCallExecutedEvent` — tool name, duration, success per tool
call
+* `OpenAIAgenticLoopCompletedEvent` — iteration count, cumulative tokens, stop
reason
+
+These events provide a lightweight integration point for future OpenTelemetry
GenAI instrumentation.
+
=== Conversation Memory with MCP Tools
When `conversationMemory=true`, the full tool call chain is stored in the
conversation history exchange property (`CamelOpenAIConversationHistory`). This
includes:
diff --git a/components/camel-ai/camel-openai/src/main/docs/openai-mcp.adoc
b/components/camel-ai/camel-openai/src/main/docs/openai-mcp.adoc
index 5f970c739d6d..1f5b17c1828c 100644
--- a/components/camel-ai/camel-openai/src/main/docs/openai-mcp.adoc
+++ b/components/camel-ai/camel-openai/src/main/docs/openai-mcp.adoc
@@ -481,8 +481,24 @@ The following headers are set after the agentic loop
completes:
| `CamelOpenAIToolIterations` | Integer | Number of tool call iterations
performed
| `CamelOpenAIMcpToolCalls` | List<String> | Ordered list of tool names called
during the loop
| `CamelOpenAIMcpReturnDirect` | Boolean | `true` if the response came
directly from a tool with `returnDirect`
+| `CamelOpenAIAgenticPromptTokens` | Long | Cumulative prompt tokens across
all agentic loop iterations
+| `CamelOpenAIAgenticCompletionTokens` | Long | Cumulative completion tokens
across all agentic loop iterations
+| `CamelOpenAIAgenticTotalTokens` | Long | Cumulative total tokens across all
agentic loop iterations
|===
+The exchange property `CamelOpenAIAgenticTrace` holds a
`List<AgenticIterationTrace>` with per-iteration
+breakdown: iteration number, tool calls (name, truncated arguments and
results, duration, success flag),
+token usage for that model call, and iteration duration. Downstream processors
in the same route can inspect
+this property for debugging, auditing, or routing decisions without waiting
for external observability backends.
+
+Custom `CamelEvent` instances are also emitted during the agentic loop for
EventNotifier listeners:
+
+* `OpenAIAgenticLoopStartedEvent` — tool count and max iterations
+* `OpenAIAgenticToolCallExecutedEvent` — tool name, duration, success per tool
call
+* `OpenAIAgenticLoopCompletedEvent` — iteration count, cumulative tokens, stop
reason
+
+These events provide a lightweight integration point for future OpenTelemetry
GenAI instrumentation.
+
=== Conversation Memory with MCP Tools
When `conversationMemory=true`, the full tool call chain is stored in the
conversation history exchange property (`CamelOpenAIConversationHistory`). This
includes:
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/AbstractOpenAIExchangeEvent.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/AbstractOpenAIExchangeEvent.java
new file mode 100644
index 000000000000..9e51ada65c9e
--- /dev/null
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/AbstractOpenAIExchangeEvent.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.openai;
+
+import java.io.Serial;
+import java.util.EventObject;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.spi.CamelEvent;
+
+/**
+ * Base class for OpenAI agentic loop {@link CamelEvent} notifications.
+ * <p>
+ * These are {@link CamelEvent.Type#Custom} events and are not {@link
CamelEvent.ExchangeEvent} instances so they do not
+ * pollute generic exchange lifecycle metrics. {@link #getExchange()} is
public so {@code EventNotifier} listeners can
+ * correlate events with the exchange (for example route id or exchange id)
without implementing {@code ExchangeEvent}.
+ */
+abstract class AbstractOpenAIExchangeEvent extends EventObject implements
CamelEvent {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private final Exchange exchange;
+ private long timestamp;
+
+ protected AbstractOpenAIExchangeEvent(Exchange exchange) {
+ super(exchange);
+ this.exchange = exchange;
+ }
+
+ public Exchange getExchange() {
+ return exchange;
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public void setTimestamp(long timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ @Override
+ public Type getType() {
+ return Type.Custom;
+ }
+}
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticTokenTracker.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/AgenticIterationTrace.java
similarity index 51%
copy from
components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticTokenTracker.java
copy to
components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/AgenticIterationTrace.java
index 9103fe1b603e..36d9295c4d4e 100644
---
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticTokenTracker.java
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/AgenticIterationTrace.java
@@ -16,38 +16,22 @@
*/
package org.apache.camel.component.openai;
-import com.openai.models.chat.completions.ChatCompletion;
-import com.openai.models.completions.CompletionUsage;
+import java.util.List;
/**
- * Tracks cumulative token usage across the MCP agentic loop.
+ * Trace entry for one iteration of the OpenAI MCP agentic loop.
+ *
+ * @param iteration 1-based sequence number for each model call in the
agentic loop
+ * @param toolCalls tool calls executed in this iteration, empty when
the model produced a final answer
+ * @param promptTokens prompt tokens consumed by the model call in this
iteration
+ * @param completionTokens completion tokens consumed by the model call in
this iteration
+ * @param durationMs wall-clock duration of the iteration in milliseconds
+ * @since 4.23
*/
-final class OpenAIAgenticTokenTracker {
-
- private long promptTokens;
- private long completionTokens;
-
- void addUsage(ChatCompletion response) {
- if (response == null) {
- return;
- }
- response.usage().ifPresent(this::addUsage);
- }
-
- void addUsage(CompletionUsage usage) {
- promptTokens += usage.promptTokens();
- completionTokens += usage.completionTokens();
- }
-
- long getPromptTokens() {
- return promptTokens;
- }
-
- long getCompletionTokens() {
- return completionTokens;
- }
-
- long getTotalTokens() {
- return promptTokens + completionTokens;
- }
+public record AgenticIterationTrace(
+ int iteration,
+ List<AgenticToolCallTrace> toolCalls,
+ long promptTokens,
+ long completionTokens,
+ long durationMs) {
}
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticTokenTracker.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/AgenticToolCallTrace.java
similarity index 51%
copy from
components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticTokenTracker.java
copy to
components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/AgenticToolCallTrace.java
index 9103fe1b603e..5ef1bd38afd0 100644
---
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticTokenTracker.java
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/AgenticToolCallTrace.java
@@ -16,38 +16,20 @@
*/
package org.apache.camel.component.openai;
-import com.openai.models.chat.completions.ChatCompletion;
-import com.openai.models.completions.CompletionUsage;
-
/**
- * Tracks cumulative token usage across the MCP agentic loop.
+ * Trace entry for a single tool call within an agentic loop iteration.
+ *
+ * @param toolName the tool that was invoked
+ * @param argumentsSummary truncated JSON arguments passed to the tool
+ * @param resultSummary truncated textual result returned to the model
+ * @param durationMs wall-clock duration of the tool execution in
milliseconds
+ * @param success whether the tool call completed without an error
result
+ * @since 4.23
*/
-final class OpenAIAgenticTokenTracker {
-
- private long promptTokens;
- private long completionTokens;
-
- void addUsage(ChatCompletion response) {
- if (response == null) {
- return;
- }
- response.usage().ifPresent(this::addUsage);
- }
-
- void addUsage(CompletionUsage usage) {
- promptTokens += usage.promptTokens();
- completionTokens += usage.completionTokens();
- }
-
- long getPromptTokens() {
- return promptTokens;
- }
-
- long getCompletionTokens() {
- return completionTokens;
- }
-
- long getTotalTokens() {
- return promptTokens + completionTokens;
- }
+public record AgenticToolCallTrace(
+ String toolName,
+ String argumentsSummary,
+ String resultSummary,
+ long durationMs,
+ boolean success) {
}
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/McpToolCallExecutor.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/McpToolCallExecutor.java
index a5ab71a756d7..54ccc06e2987 100644
---
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/McpToolCallExecutor.java
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/McpToolCallExecutor.java
@@ -76,7 +76,8 @@ class McpToolCallExecutor extends ServiceSupport {
* @param content the textual result to feed back to the model
* @param returnDirect whether the call succeeded and the tool is
annotated with {@code returnDirect}
*/
- record ToolResult(String toolCallId, String toolName, String content,
boolean returnDirect) {
+ record ToolResult(String toolCallId, String toolName, String content,
boolean returnDirect, long durationMs,
+ boolean success) {
}
@Override
@@ -160,7 +161,7 @@ class McpToolCallExecutor extends ServiceSupport {
if (timedOut != null) {
failure = failure != null ? failure : timedOut;
} else {
- results[i] = errorResult(toolCall, "Error: tool execution
timed out after " + timeout + " ms");
+ results[i] = timeoutResult(toolCall, timeout);
}
} catch (ExecutionException e) {
// executeOne only throws when the configured strategy is to
fail the exchange
@@ -211,13 +212,14 @@ class McpToolCallExecutor extends ServiceSupport {
}
private ToolResult executeOne(ChatCompletionMessageToolCall toolCall,
McpToolState toolState) throws Exception {
+ long startNanos = System.nanoTime();
OpenAIConfiguration config = endpoint.getConfiguration();
String toolName = toolCall.asFunction().function().name();
String argsJson = toolCall.asFunction().function().arguments();
AiToolSpec routeSpec = toolState.routeTools().get(toolName);
if (routeSpec != null) {
- return executeRouteTool(toolCall, routeSpec, toolState, config);
+ return timed(startNanos, executeRouteTool(toolCall, routeSpec,
toolState, config));
}
McpSyncClient mcpClient = toolState.toolClientMap().get(toolName);
@@ -228,8 +230,8 @@ class McpToolCallExecutor extends ServiceSupport {
// repromptModel: send a corrective tool result listing available
tools
String available = String.join(", ", toolState.knownToolNames());
LOG.warn("Hallucinated tool name '{}', sending corrective result
to model", toolName);
- return errorResult(toolCall,
- "Error: tool '" + toolName + "' does not exist. Available
tools: " + available);
+ return timed(startNanos, errorResult(toolCall,
+ "Error: tool '" + toolName + "' does not exist. Available
tools: " + available));
}
LOG.debug("Executing MCP tool '{}' with args: {}", toolName, argsJson);
@@ -241,25 +243,26 @@ class McpToolCallExecutor extends ServiceSupport {
if (Boolean.TRUE.equals(toolResult.isError())) {
String content = "Error: " +
extractTextContent(toolResult.content());
LOG.warn("MCP tool '{}' returned error: {}", toolName,
content);
- return errorResult(toolCall, content);
+ return timed(startNanos, errorResult(toolCall, content));
}
String content = extractTextContent(toolResult.content());
LOG.debug("Tool '{}' result: {}", toolName, content);
- return new ToolResult(
- toolCall.asFunction().id(), toolName, content,
toolState.returnDirectTools().contains(toolName));
+ return timed(startNanos, new ToolResult(
+ toolCall.asFunction().id(), toolName, content,
toolState.returnDirectTools().contains(toolName), 0,
+ true));
} catch (JsonProcessingException e) {
if (config.getToolExecutionErrorStrategy() ==
ToolExecutionErrorStrategy.FAIL_EXCHANGE) {
throw e;
}
LOG.warn("Invalid tool arguments for '{}': {}", toolName,
argsJson, e);
- return errorResult(toolCall, "Error: invalid tool arguments: " +
e.getMessage());
+ return timed(startNanos, errorResult(toolCall, "Error: invalid
tool arguments: " + e.getMessage()));
} catch (Exception e) {
if (config.getToolExecutionErrorStrategy() ==
ToolExecutionErrorStrategy.FAIL_EXCHANGE) {
throw e;
}
LOG.warn("MCP tool '{}' execution failed: {}", toolName,
e.getMessage(), e);
- return errorResult(toolCall, "Error: Tool execution failed: " +
e.getMessage());
+ return timed(startNanos, errorResult(toolCall, "Error: Tool
execution failed: " + e.getMessage()));
}
}
@@ -283,7 +286,7 @@ class McpToolCallExecutor extends ServiceSupport {
LOG.debug("Route tool '{}' result: {}", toolName,
success.value());
return new ToolResult(
toolCall.asFunction().id(), toolName,
success.value(),
- toolState.returnDirectTools().contains(toolName));
+ toolState.returnDirectTools().contains(toolName),
0, true);
} else if (result instanceof AiToolResult.ArgumentError error)
{
LOG.warn("Route tool '{}' argument error: {}", toolName,
error.message());
return errorResult(toolCall, "Error: invalid tool
arguments: " + error.message());
@@ -312,7 +315,25 @@ class McpToolCallExecutor extends ServiceSupport {
private static ToolResult errorResult(ChatCompletionMessageToolCall
toolCall, String content) {
return new ToolResult(
- toolCall.asFunction().id(),
toolCall.asFunction().function().name(), content, false);
+ toolCall.asFunction().id(),
toolCall.asFunction().function().name(), content, false, 0, false);
+ }
+
+ private static ToolResult timeoutResult(ChatCompletionMessageToolCall
toolCall, long timeoutMs) {
+ // Use the configured parallelToolTimeout as durationMs: the wait
already consumed that budget.
+ return new ToolResult(
+ toolCall.asFunction().id(),
+ toolCall.asFunction().function().name(),
+ "Error: tool execution timed out after " + timeoutMs + " ms",
+ false,
+ timeoutMs,
+ false);
+ }
+
+ private static ToolResult timed(long startNanos, ToolResult result) {
+ long durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() -
startNanos);
+ return new ToolResult(
+ result.toolCallId(), result.toolName(), result.content(),
result.returnDirect(), durationMs,
+ result.success());
}
private static String extractTextContent(List<McpSchema.Content> contents)
{
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticLoopCompletedEvent.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticLoopCompletedEvent.java
new file mode 100644
index 000000000000..8fc4de329dd9
--- /dev/null
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticLoopCompletedEvent.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.openai;
+
+import java.io.Serial;
+
+import org.apache.camel.Exchange;
+
+/**
+ * Fired when an OpenAI MCP agentic loop completes on an exchange.
+ *
+ * @since 4.23
+ */
+public final class OpenAIAgenticLoopCompletedEvent extends
AbstractOpenAIExchangeEvent {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private final int iterationCount;
+ private final long totalTokens;
+ private final String stopReason;
+
+ public OpenAIAgenticLoopCompletedEvent(
+ Exchange exchange, int
iterationCount, long totalTokens, String stopReason) {
+ super(exchange);
+ this.iterationCount = iterationCount;
+ this.totalTokens = totalTokens;
+ this.stopReason = stopReason;
+ }
+
+ public int getIterationCount() {
+ return iterationCount;
+ }
+
+ public long getTotalTokens() {
+ return totalTokens;
+ }
+
+ public String getStopReason() {
+ return stopReason;
+ }
+
+ @Override
+ public String toString() {
+ return "OpenAIAgenticLoopCompletedEvent{iterationCount=" +
iterationCount + ", totalTokens=" + totalTokens
+ + ", stopReason='" + stopReason + "'}";
+ }
+}
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticTokenTracker.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticLoopStartedEvent.java
similarity index 51%
copy from
components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticTokenTracker.java
copy to
components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticLoopStartedEvent.java
index 9103fe1b603e..5042fbb16f78 100644
---
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticTokenTracker.java
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticLoopStartedEvent.java
@@ -16,38 +16,39 @@
*/
package org.apache.camel.component.openai;
-import com.openai.models.chat.completions.ChatCompletion;
-import com.openai.models.completions.CompletionUsage;
+import java.io.Serial;
+
+import org.apache.camel.Exchange;
/**
- * Tracks cumulative token usage across the MCP agentic loop.
+ * Fired when an OpenAI MCP agentic loop starts on an exchange.
+ *
+ * @since 4.23
*/
-final class OpenAIAgenticTokenTracker {
+public final class OpenAIAgenticLoopStartedEvent extends
AbstractOpenAIExchangeEvent {
- private long promptTokens;
- private long completionTokens;
+ @Serial
+ private static final long serialVersionUID = 1L;
- void addUsage(ChatCompletion response) {
- if (response == null) {
- return;
- }
- response.usage().ifPresent(this::addUsage);
- }
+ private final int toolCount;
+ private final int maxIterations;
- void addUsage(CompletionUsage usage) {
- promptTokens += usage.promptTokens();
- completionTokens += usage.completionTokens();
+ public OpenAIAgenticLoopStartedEvent(Exchange exchange, int toolCount, int
maxIterations) {
+ super(exchange);
+ this.toolCount = toolCount;
+ this.maxIterations = maxIterations;
}
- long getPromptTokens() {
- return promptTokens;
+ public int getToolCount() {
+ return toolCount;
}
- long getCompletionTokens() {
- return completionTokens;
+ public int getMaxIterations() {
+ return maxIterations;
}
- long getTotalTokens() {
- return promptTokens + completionTokens;
+ @Override
+ public String toString() {
+ return "OpenAIAgenticLoopStartedEvent{toolCount=" + toolCount + ",
maxIterations=" + maxIterations + "}";
}
}
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticObservability.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticObservability.java
new file mode 100644
index 000000000000..c2e2ba2cdb2e
--- /dev/null
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticObservability.java
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.openai;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import com.openai.models.chat.completions.ChatCompletionMessageToolCall;
+import org.apache.camel.Exchange;
+import org.apache.camel.spi.CamelEvent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Collects per-iteration agentic traces and emits lightweight lifecycle
events for EventNotifier listeners.
+ */
+final class OpenAIAgenticObservability {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(OpenAIAgenticObservability.class);
+ static final int MAX_TRACE_TEXT_LENGTH = 512;
+
+ private final Exchange exchange;
+ private final List<AgenticIterationTrace> trace = new ArrayList<>();
+ private boolean loopStarted;
+ private boolean loopCompleted;
+
+ OpenAIAgenticObservability(Exchange exchange) {
+ this.exchange = exchange;
+ }
+
+ List<AgenticIterationTrace> trace() {
+ return trace;
+ }
+
+ void onLoopStarted(int toolCount, int maxIterations) {
+ loopStarted = true;
+ notify(new OpenAIAgenticLoopStartedEvent(exchange, toolCount,
maxIterations));
+ }
+
+ void onLoopCompleted(int iterationCount, OpenAIAgenticTokenTracker
tokenTracker, String stopReason) {
+ loopCompleted = true;
+ notify(new OpenAIAgenticLoopCompletedEvent(
+ exchange, iterationCount, tokenTracker.getTotalTokens(),
stopReason));
+ publishTrace();
+ }
+
+ void onToolCallExecuted(int iteration, McpToolCallExecutor.ToolResult
result) {
+ notify(new OpenAIAgenticToolCallExecutedEvent(
+ exchange, iteration, result.toolName(), result.durationMs(),
result.success()));
+ }
+
+ void publishTrace() {
+ exchange.setProperty(OpenAIConstants.AGENTIC_TRACE,
List.copyOf(trace));
+ }
+
+ void finalizeObservability(OpenAIAgenticTokenTracker tokenTracker, int
iterationCount, String stopReason) {
+ if (loopStarted && !loopCompleted) {
+ onLoopCompleted(iterationCount, tokenTracker, stopReason);
+ } else if (!trace.isEmpty()) {
+ publishTrace();
+ }
+ }
+
+ AgenticIterationTrace recordIteration(
+ int iteration,
+ long iterationStartNanos,
+ long promptTokens,
+ long completionTokens,
+ List<ChatCompletionMessageToolCall> requestedToolCalls,
+ List<McpToolCallExecutor.ToolResult> toolResults) {
+ List<AgenticToolCallTrace> toolTraces = new
ArrayList<>(requestedToolCalls.size());
+ for (int i = 0; i < requestedToolCalls.size(); i++) {
+ ChatCompletionMessageToolCall toolCall = requestedToolCalls.get(i);
+ McpToolCallExecutor.ToolResult result = toolResults.get(i);
+ onToolCallExecuted(iteration, result);
+ toolTraces.add(new AgenticToolCallTrace(
+ result.toolName(),
+ summarize(toolCall.asFunction().function().arguments()),
+ summarize(result.content()),
+ result.durationMs(),
+ result.success()));
+ }
+ long durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() -
iterationStartNanos);
+ AgenticIterationTrace iterationTrace
+ = new AgenticIterationTrace(iteration,
List.copyOf(toolTraces), promptTokens, completionTokens, durationMs);
+ trace.add(iterationTrace);
+ return iterationTrace;
+ }
+
+ void recordFinalIteration(
+ int iteration,
+ long iterationStartNanos,
+ long promptTokens,
+ long completionTokens) {
+ long durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() -
iterationStartNanos);
+ trace.add(new AgenticIterationTrace(iteration, List.of(),
promptTokens, completionTokens, durationMs));
+ }
+
+ static String summarize(String value) {
+ if (value == null) {
+ return null;
+ }
+ if (value.length() <= MAX_TRACE_TEXT_LENGTH) {
+ return value;
+ }
+ return value.substring(0, MAX_TRACE_TEXT_LENGTH) + "...";
+ }
+
+ private void notify(CamelEvent event) {
+ if
(exchange.getContext().getManagementStrategy().getEventNotifiers().isEmpty()) {
+ return;
+ }
+ try {
+ exchange.getContext().getManagementStrategy().notify(event);
+ } catch (Exception e) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Unable to notify agentic lifecycle event {}",
event.getClass().getSimpleName(), e);
+ }
+ }
+ }
+}
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticTokenTracker.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticTokenTracker.java
index 9103fe1b603e..016bee75e971 100644
---
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticTokenTracker.java
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticTokenTracker.java
@@ -27,6 +27,21 @@ final class OpenAIAgenticTokenTracker {
private long promptTokens;
private long completionTokens;
+ record Snapshot(long promptTokens, long completionTokens) {
+ }
+
+ Snapshot snapshot() {
+ return new Snapshot(promptTokens, completionTokens);
+ }
+
+ long promptTokensSince(Snapshot before) {
+ return promptTokens - before.promptTokens();
+ }
+
+ long completionTokensSince(Snapshot before) {
+ return completionTokens - before.completionTokens();
+ }
+
void addUsage(ChatCompletion response) {
if (response == null) {
return;
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticToolCallExecutedEvent.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticToolCallExecutedEvent.java
new file mode 100644
index 000000000000..10f433eca0ed
--- /dev/null
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticToolCallExecutedEvent.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.openai;
+
+import java.io.Serial;
+
+import org.apache.camel.Exchange;
+
+/**
+ * Fired after a single tool call completes during an OpenAI MCP agentic loop.
+ *
+ * @since 4.23
+ */
+public final class OpenAIAgenticToolCallExecutedEvent extends
AbstractOpenAIExchangeEvent {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private final int iteration;
+ private final String toolName;
+ private final long durationMs;
+ private final boolean success;
+
+ public OpenAIAgenticToolCallExecutedEvent(
+ Exchange exchange, int
iteration, String toolName, long durationMs,
+ boolean success) {
+ super(exchange);
+ this.iteration = iteration;
+ this.toolName = toolName;
+ this.durationMs = durationMs;
+ this.success = success;
+ }
+
+ public int getIteration() {
+ return iteration;
+ }
+
+ public String getToolName() {
+ return toolName;
+ }
+
+ public long getDurationMs() {
+ return durationMs;
+ }
+
+ public boolean isSuccess() {
+ return success;
+ }
+
+ @Override
+ public String toString() {
+ return "OpenAIAgenticToolCallExecutedEvent{iteration=" + iteration +
", toolName='" + toolName
+ + "', durationMs=" + durationMs + ", success=" + success + "}";
+ }
+}
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConstants.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConstants.java
index a6c51fcee2d1..e81816d01167 100644
---
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConstants.java
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConstants.java
@@ -94,7 +94,9 @@ public final class OpenAIConstants {
@Metadata(description = "Cumulative total tokens consumed across all
agentic loop iterations", javaType = "Long")
public static final String AGENTIC_TOTAL_TOKENS =
"CamelOpenAIAgenticTotalTokens";
- // Output Exchange Properties
+ // Output Exchange Properties (documented in openai-mcp.adoc; omitted from
catalog headers)
+ @Metadata(skip = true)
+ public static final String AGENTIC_TRACE = "CamelOpenAIAgenticTrace";
@Metadata(description = "The complete OpenAI chat completion response
object",
javaType = "com.openai.models.chat.completions.ChatCompletion")
public static final String RESPONSE = "CamelOpenAIResponse";
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
index b26b1d4fddaa..c938cc1bf391 100644
---
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
@@ -507,92 +507,134 @@ public class OpenAIProducer extends DefaultAsyncProducer
{
List<ChatCompletionMessageParam> agenticMessages = new ArrayList<>();
List<String> toolCallsLog = new ArrayList<>();
OpenAIAgenticTokenTracker tokenTracker = new
OpenAIAgenticTokenTracker();
+ OpenAIAgenticObservability observability = new
OpenAIAgenticObservability(exchange);
+
observability.onLoopStarted(getEndpoint().getMcpToolState().knownToolNames().size(),
maxIterations);
int iteration = 0;
+ int modelCall = 0;
+ String stopReason = "unknown";
- while (iteration < maxIterations) {
- ChatCompletion response = createChatCompletion(exchange,
paramsBuilder.build());
- tokenTracker.addUsage(response);
- setAgenticTokenHeaders(exchange.getMessage(), tokenTracker);
-
- ChatCompletion.Choice choice = requireFirstChoice(exchange,
response);
-
- if (!isToolCallsFinishReason(choice)) {
- // Final LLM response
- LOG.debug("Agentic loop completed after {} iterations, finish
reason: {}", iteration,
- getFinishReasonString(choice));
- String content = choice.message().content().orElse("");
- content = processThinkingContent(exchange, content, config);
- exchange.getMessage().setBody(content);
- extractReasoningContent(exchange, choice.message());
- extractAdditionalResponseHeaders(exchange, choice.message());
- setResponseHeaders(exchange.getMessage(), response);
-
exchange.getMessage().setHeader(OpenAIConstants.TOOL_ITERATIONS, iteration);
-
exchange.getMessage().setHeader(OpenAIConstants.MCP_TOOL_CALLS, toolCallsLog);
-
exchange.getMessage().setHeader(OpenAIConstants.MCP_RETURN_DIRECT, false);
- if (config.isStoreFullResponse()) {
- exchange.setProperty(OpenAIConstants.RESPONSE, response);
+ try {
+ while (iteration < maxIterations) {
+ modelCall++;
+ long iterationStartNanos = System.nanoTime();
+ OpenAIAgenticTokenTracker.Snapshot tokensBefore =
tokenTracker.snapshot();
+
+ ChatCompletion response = createChatCompletion(exchange,
paramsBuilder.build());
+ tokenTracker.addUsage(response);
+ setAgenticTokenHeaders(exchange.getMessage(), tokenTracker);
+
+ long iterationPromptTokens =
tokenTracker.promptTokensSince(tokensBefore);
+ long iterationCompletionTokens =
tokenTracker.completionTokensSince(tokensBefore);
+
+ ChatCompletion.Choice choice = requireFirstChoice(exchange,
response);
+
+ if (!isToolCallsFinishReason(choice)) {
+ // Final LLM response
+ LOG.debug("Agentic loop completed after {} iterations,
finish reason: {}", iteration,
+ getFinishReasonString(choice));
+ stopReason = getFinishReasonString(choice);
+ observability.recordFinalIteration(
+ modelCall, iterationStartNanos,
iterationPromptTokens, iterationCompletionTokens);
+ observability.onLoopCompleted(iteration, tokenTracker,
stopReason);
+ String content = choice.message().content().orElse("");
+ content = processThinkingContent(exchange, content,
config);
+ exchange.getMessage().setBody(content);
+ extractReasoningContent(exchange, choice.message());
+ extractAdditionalResponseHeaders(exchange,
choice.message());
+ setResponseHeaders(exchange.getMessage(), response);
+
exchange.getMessage().setHeader(OpenAIConstants.TOOL_ITERATIONS, iteration);
+
exchange.getMessage().setHeader(OpenAIConstants.MCP_TOOL_CALLS, toolCallsLog);
+
exchange.getMessage().setHeader(OpenAIConstants.MCP_RETURN_DIRECT, false);
+ if (config.isStoreFullResponse()) {
+ exchange.setProperty(OpenAIConstants.RESPONSE,
response);
+ }
+ updateConversationHistory(exchange, agenticMessages,
response);
+ return;
}
- updateConversationHistory(exchange, agenticMessages, response);
- return;
- }
- enforceAgenticTokenBudget(config, tokenTracker, iteration);
-
- iteration++;
- LOG.debug("Iteration {}: model requested {} tool call(s)",
iteration,
- choice.message().toolCalls().map(List::size).orElse(0));
-
- // Add assistant message with tool_calls to conversation
- ChatCompletionMessage assistantMsg = choice.message();
- List<ChatCompletionMessageToolCall> toolCalls =
assistantMsg.toolCalls().orElse(List.of());
- ChatCompletionMessageParam assistantParam =
ChatCompletionMessageParam.ofAssistant(
- ChatCompletionAssistantMessageParam.builder()
- .toolCalls(toolCalls)
- .build());
- paramsBuilder.addMessage(assistantParam);
- agenticMessages.add(assistantParam);
-
- // Record the requested tools up front so the log keeps the
model's ordering regardless of
- // whether the batch is executed sequentially or in parallel
- for (ChatCompletionMessageToolCall toolCall : toolCalls) {
- toolCallsLog.add(toolCall.asFunction().function().name());
- }
+ if (tokenBudgetExceeded(config, tokenTracker)) {
+ observability.recordFinalIteration(
+ modelCall, iterationStartNanos,
iterationPromptTokens, iterationCompletionTokens);
+ stopReason = "token_budget_exceeded";
+ throw new IllegalStateException(
+ "Max agentic tokens (%d) exceeded at iteration %d.
Cumulative usage: prompt=%d, completion=%d, total=%d"
+ .formatted(config.getMaxAgenticTokens(),
iteration, tokenTracker.getPromptTokens(),
+
tokenTracker.getCompletionTokens(), tokenTracker.getTotalTokens()));
+ }
- // Execute all tool calls in this batch
- List<McpToolCallExecutor.ToolResult> batchResults =
toolCallExecutor.execute(toolCalls);
- boolean allReturnDirect =
batchResults.stream().allMatch(McpToolCallExecutor.ToolResult::returnDirect);
-
- // returnDirect check: if ALL tools in this batch are
returnDirect, short-circuit
- if (allReturnDirect && !batchResults.isEmpty()) {
- LOG.debug("All tools in batch have returnDirect=true,
short-circuiting agentic loop");
- String directResult = batchResults.stream()
- .map(McpToolCallExecutor.ToolResult::content)
- .collect(Collectors.joining("\n"));
-
- exchange.getMessage().setBody(directResult);
- setResponseHeaders(exchange.getMessage(), response);
-
exchange.getMessage().setHeader(OpenAIConstants.TOOL_ITERATIONS, iteration);
-
exchange.getMessage().setHeader(OpenAIConstants.MCP_TOOL_CALLS, toolCallsLog);
-
exchange.getMessage().setHeader(OpenAIConstants.MCP_RETURN_DIRECT, true);
- updateConversationHistory(exchange, agenticMessages,
directResult);
- return;
- }
+ iteration++;
+ LOG.debug("Iteration {}: model requested {} tool call(s)",
iteration,
+
choice.message().toolCalls().map(List::size).orElse(0));
- // Normal path: feed tool results back to LLM
- LOG.debug("Feeding {} tool result(s) back to the model",
batchResults.size());
- for (McpToolCallExecutor.ToolResult entry : batchResults) {
- ChatCompletionMessageParam toolMsg =
ChatCompletionMessageParam.ofTool(
- ChatCompletionToolMessageParam.builder()
- .toolCallId(entry.toolCallId())
- .content(entry.content())
+ // Add assistant message with tool_calls to conversation
+ ChatCompletionMessage assistantMsg = choice.message();
+ List<ChatCompletionMessageToolCall> toolCalls =
assistantMsg.toolCalls().orElse(List.of());
+ ChatCompletionMessageParam assistantParam =
ChatCompletionMessageParam.ofAssistant(
+ ChatCompletionAssistantMessageParam.builder()
+ .toolCalls(toolCalls)
.build());
- paramsBuilder.addMessage(toolMsg);
- agenticMessages.add(toolMsg);
+ paramsBuilder.addMessage(assistantParam);
+ agenticMessages.add(assistantParam);
+
+ // Record the requested tools up front so the log keeps the
model's ordering regardless of
+ // whether the batch is executed sequentially or in parallel
+ for (ChatCompletionMessageToolCall toolCall : toolCalls) {
+ toolCallsLog.add(toolCall.asFunction().function().name());
+ }
+
+ // Execute all tool calls in this batch
+ List<McpToolCallExecutor.ToolResult> batchResults =
toolCallExecutor.execute(toolCalls);
+ observability.recordIteration(
+ modelCall, iterationStartNanos, iterationPromptTokens,
iterationCompletionTokens, toolCalls,
+ batchResults);
+ boolean allReturnDirect =
batchResults.stream().allMatch(McpToolCallExecutor.ToolResult::returnDirect);
+
+ // returnDirect check: if ALL tools in this batch are
returnDirect, short-circuit
+ if (allReturnDirect && !batchResults.isEmpty()) {
+ LOG.debug("All tools in batch have returnDirect=true,
short-circuiting agentic loop");
+ String directResult = batchResults.stream()
+ .map(McpToolCallExecutor.ToolResult::content)
+ .collect(Collectors.joining("\n"));
+
+ exchange.getMessage().setBody(directResult);
+ setResponseHeaders(exchange.getMessage(), response);
+
exchange.getMessage().setHeader(OpenAIConstants.TOOL_ITERATIONS, iteration);
+
exchange.getMessage().setHeader(OpenAIConstants.MCP_TOOL_CALLS, toolCallsLog);
+
exchange.getMessage().setHeader(OpenAIConstants.MCP_RETURN_DIRECT, true);
+ stopReason = "return_direct";
+ observability.onLoopCompleted(iteration, tokenTracker,
stopReason);
+ updateConversationHistory(exchange, agenticMessages,
directResult);
+ return;
+ }
+
+ // Normal path: feed tool results back to LLM
+ LOG.debug("Feeding {} tool result(s) back to the model",
batchResults.size());
+ for (McpToolCallExecutor.ToolResult entry : batchResults) {
+ ChatCompletionMessageParam toolMsg =
ChatCompletionMessageParam.ofTool(
+ ChatCompletionToolMessageParam.builder()
+ .toolCallId(entry.toolCallId())
+ .content(entry.content())
+ .build());
+ paramsBuilder.addMessage(toolMsg);
+ agenticMessages.add(toolMsg);
+ }
}
- }
- throw new IllegalStateException(
- "Max tool iterations (%d) exceeded. Tools called:
%s".formatted(maxIterations, toolCallsLog));
+ stopReason = "max_iterations_exceeded";
+ observability.onLoopCompleted(maxIterations, tokenTracker,
stopReason);
+ throw new IllegalStateException(
+ "Max tool iterations (%d) exceeded. Tools called:
%s".formatted(maxIterations, toolCallsLog));
+ } catch (IllegalStateException e) {
+ if ("unknown".equals(stopReason)) {
+ stopReason = "error";
+ }
+ throw e;
+ } catch (Exception e) {
+ stopReason = "error";
+ throw e;
+ } finally {
+ observability.finalizeObservability(tokenTracker, iteration,
stopReason);
+ }
}
private void setAgenticTokenHeaders(Message message,
OpenAIAgenticTokenTracker tokenTracker) {
@@ -601,16 +643,9 @@ public class OpenAIProducer extends DefaultAsyncProducer {
message.setHeader(OpenAIConstants.AGENTIC_TOTAL_TOKENS,
tokenTracker.getTotalTokens());
}
- private void enforceAgenticTokenBudget(
- OpenAIConfiguration config, OpenAIAgenticTokenTracker
tokenTracker, int iteration) {
+ private static boolean tokenBudgetExceeded(OpenAIConfiguration config,
OpenAIAgenticTokenTracker tokenTracker) {
long maxAgenticTokens = config.getMaxAgenticTokens();
- if (maxAgenticTokens <= 0 || tokenTracker.getTotalTokens() <=
maxAgenticTokens) {
- return;
- }
- throw new IllegalStateException(
- "Max agentic tokens (%d) exceeded at iteration %d. Cumulative
usage: prompt=%d, completion=%d, total=%d"
- .formatted(maxAgenticTokens, iteration,
tokenTracker.getPromptTokens(),
- tokenTracker.getCompletionTokens(),
tokenTracker.getTotalTokens()));
+ return maxAgenticTokens > 0 && tokenTracker.getTotalTokens() >
maxAgenticTokens;
}
private void processStreaming(Exchange exchange,
ChatCompletionCreateParams params) {
diff --git
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticEventNotifierTest.java
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticEventNotifierTest.java
new file mode 100644
index 000000000000..c9810103995a
--- /dev/null
+++
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticEventNotifierTest.java
@@ -0,0 +1,185 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.openai;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import io.modelcontextprotocol.client.McpSyncClient;
+import io.modelcontextprotocol.spec.McpSchema;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.CamelEvent;
+import org.apache.camel.support.EventNotifierSupport;
+import org.apache.camel.test.infra.openai.mock.OpenAIMock;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class OpenAIAgenticEventNotifierTest extends CamelTestSupport {
+
+ private static final String ENDPOINT_URI =
"openai:chat-completion?model=gpt-5&apiKey=dummy"
+ +
"&autoToolExecution=true&baseUrl=%s/v1";
+
+ private final List<CamelEvent> events = new CopyOnWriteArrayList<>();
+
+ @RegisterExtension
+ public OpenAIMock openAIMock = new OpenAIMock().builder()
+ .when("call one tool")
+ .invokeTool("get_weather")
+ .withParam("city", "London")
+ .replyWith("The weather in London is sunny.")
+ .end()
+ .when("expensive tool call")
+ .withUsage(70, 50)
+ .invokeTool("get_weather")
+ .withParam("city", "Paris")
+ .replyWith("Should not reach this response")
+ .end()
+ .build();
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:mcp-chat")
+
.toF("openai:chat-completion?model=gpt-5&apiKey=dummy&autoToolExecution=true&baseUrl=%s/v1",
+ openAIMock.getBaseUrl());
+
+ from("direct:token-budget-fail")
+
.toF("openai:chat-completion?model=gpt-5&apiKey=dummy&autoToolExecution=true"
+ +
"&maxAgenticTokens=100&maxToolIterations=5&baseUrl=%s/v1",
+ openAIMock.getBaseUrl());
+ }
+ };
+ }
+
+ @BeforeEach
+ void registerEventNotifier() {
+ events.clear();
+ context.getManagementStrategy().addEventNotifier(new
EventNotifierSupport() {
+ @Override
+ public void notify(CamelEvent event) {
+ if (event.getType() == CamelEvent.Type.Custom) {
+ events.add(event);
+ }
+ }
+
+ @Override
+ public boolean isEnabled(CamelEvent event) {
+ return event.getType() == CamelEvent.Type.Custom;
+ }
+ });
+ }
+
+ private McpSyncClient createMockMcpClient(String resultText) {
+ McpSyncClient client = mock(McpSyncClient.class);
+ McpSchema.CallToolResult result = McpSchema.CallToolResult.builder()
+ .content(List.of(new McpSchema.TextContent(null, resultText,
null)))
+ .isError(false)
+ .build();
+
when(client.callTool(any(McpSchema.CallToolRequest.class))).thenReturn(result);
+ return client;
+ }
+
+ private void injectMcpTools(Map<String, McpSyncClient> toolClients) {
+ injectMcpTools(String.format(ENDPOINT_URI, openAIMock.getBaseUrl()),
toolClients);
+ }
+
+ private void injectMcpTools(String endpointUri, Map<String, McpSyncClient>
toolClients) {
+ OpenAIEndpoint endpoint = context.getEndpoint(endpointUri,
OpenAIEndpoint.class);
+ List<McpSchema.Tool> mcpTools = toolClients.keySet().stream()
+ .map(name -> McpSchema.Tool.builder(name, Map.of("type",
"object"))
+ .description("Mock tool: " + name)
+ .build())
+ .toList();
+ endpoint.setMcpToolState(new McpToolState(
+ McpToolConverter.convert(mcpTools),
+ toolClients,
+ Map.of(),
+ Set.of(),
+ Map.of()));
+ }
+
+ @Test
+ void shouldEmitAgenticLifecycleEvents() {
+ Map<String, McpSyncClient> toolClients = new HashMap<>();
+ toolClients.put("get_weather", createMockMcpClient("Sunny, 22°C"));
+ injectMcpTools(toolClients);
+
+ template.sendBody("direct:mcp-chat", "call one tool");
+
+ List<OpenAIAgenticLoopStartedEvent> started = new ArrayList<>();
+ List<OpenAIAgenticToolCallExecutedEvent> toolEvents = new
ArrayList<>();
+ List<OpenAIAgenticLoopCompletedEvent> completed = new ArrayList<>();
+ for (CamelEvent event : events) {
+ if (event instanceof OpenAIAgenticLoopStartedEvent startedEvent) {
+ started.add(startedEvent);
+ } else if (event instanceof OpenAIAgenticToolCallExecutedEvent
toolEvent) {
+ toolEvents.add(toolEvent);
+ } else if (event instanceof OpenAIAgenticLoopCompletedEvent
completedEvent) {
+ completed.add(completedEvent);
+ }
+ }
+
+ assertThat(started).hasSize(1);
+ assertThat(started.get(0).getMaxIterations()).isPositive();
+ assertThat(toolEvents).hasSize(1);
+ assertThat(toolEvents.get(0).getToolName()).isEqualTo("get_weather");
+ assertThat(toolEvents.get(0).getIteration()).isEqualTo(1);
+
assertThat(toolEvents.get(0).getDurationMs()).isGreaterThanOrEqualTo(0);
+ assertThat(completed).hasSize(1);
+ assertThat(completed.get(0).getIterationCount()).isEqualTo(1);
+ assertThat(completed.get(0).getTotalTokens()).isPositive();
+ assertThat(completed.get(0).getStopReason()).isNotBlank();
+ }
+
+ @Test
+ void shouldEmitLoopCompletedEventWhenTokenBudgetExceeded() {
+ Map<String, McpSyncClient> toolClients = new HashMap<>();
+ toolClients.put("get_weather", createMockMcpClient("Sunny, 22°C"));
+ String endpointUri = String.format(
+
"openai:chat-completion?model=gpt-5&apiKey=dummy&autoToolExecution=true"
+ +
"&maxAgenticTokens=100&maxToolIterations=5&baseUrl=%s/v1",
+ openAIMock.getBaseUrl());
+ injectMcpTools(endpointUri, toolClients);
+
+ Exchange exchange = template.request("direct:token-budget-fail", e ->
e.getIn().setBody("expensive tool call"));
+
+
assertThat(exchange.getException()).isInstanceOf(IllegalStateException.class);
+
+ List<OpenAIAgenticLoopCompletedEvent> completed = events.stream()
+ .filter(OpenAIAgenticLoopCompletedEvent.class::isInstance)
+ .map(OpenAIAgenticLoopCompletedEvent.class::cast)
+ .toList();
+
+ assertThat(completed).hasSize(1);
+
assertThat(completed.get(0).getStopReason()).isEqualTo("token_budget_exceeded");
+ assertThat(completed.get(0).getTotalTokens()).isEqualTo(120L);
+ }
+}
diff --git
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticTokenTrackerTest.java
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticObservabilityTest.java
similarity index 55%
copy from
components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticTokenTrackerTest.java
copy to
components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticObservabilityTest.java
index f22e6754e545..8f509155061d 100644
---
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticTokenTrackerTest.java
+++
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticObservabilityTest.java
@@ -16,30 +16,22 @@
*/
package org.apache.camel.component.openai;
-import com.openai.models.completions.CompletionUsage;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
-class OpenAIAgenticTokenTrackerTest {
+class OpenAIAgenticObservabilityTest {
@Test
- void shouldAccumulatePromptAndCompletionTokens() {
- OpenAIAgenticTokenTracker tracker = new OpenAIAgenticTokenTracker();
-
- tracker.addUsage(CompletionUsage.builder()
- .promptTokens(30)
- .completionTokens(20)
- .totalTokens(50)
- .build());
- tracker.addUsage(CompletionUsage.builder()
- .promptTokens(10)
- .completionTokens(5)
- .totalTokens(15)
- .build());
+ void shouldTruncateLongTraceText() {
+ String value =
"x".repeat(OpenAIAgenticObservability.MAX_TRACE_TEXT_LENGTH + 10);
+ assertThat(OpenAIAgenticObservability.summarize(value))
+ .hasSize(OpenAIAgenticObservability.MAX_TRACE_TEXT_LENGTH + 3)
+ .endsWith("...");
+ }
- assertThat(tracker.getPromptTokens()).isEqualTo(40);
- assertThat(tracker.getCompletionTokens()).isEqualTo(25);
- assertThat(tracker.getTotalTokens()).isEqualTo(65);
+ @Test
+ void shouldLeaveShortTraceTextUntouched() {
+
assertThat(OpenAIAgenticObservability.summarize("short")).isEqualTo("short");
}
}
diff --git
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticTokenTrackerTest.java
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticTokenTrackerTest.java
index f22e6754e545..c2cf57772b1d 100644
---
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticTokenTrackerTest.java
+++
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticTokenTrackerTest.java
@@ -42,4 +42,16 @@ class OpenAIAgenticTokenTrackerTest {
assertThat(tracker.getCompletionTokens()).isEqualTo(25);
assertThat(tracker.getTotalTokens()).isEqualTo(65);
}
+
+ @Test
+ void shouldReportTokenUsageSinceSnapshot() {
+ OpenAIAgenticTokenTracker tracker = new OpenAIAgenticTokenTracker();
+
tracker.addUsage(CompletionUsage.builder().promptTokens(10).completionTokens(5).totalTokens(15).build());
+ OpenAIAgenticTokenTracker.Snapshot snapshot = tracker.snapshot();
+
+
tracker.addUsage(CompletionUsage.builder().promptTokens(3).completionTokens(2).totalTokens(5).build());
+
+ assertThat(tracker.promptTokensSince(snapshot)).isEqualTo(3);
+ assertThat(tracker.completionTokensSince(snapshot)).isEqualTo(2);
+ }
}
diff --git
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticTraceTest.java
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticTraceTest.java
new file mode 100644
index 000000000000..928b3095a76e
--- /dev/null
+++
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticTraceTest.java
@@ -0,0 +1,251 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.openai;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import io.modelcontextprotocol.client.McpSyncClient;
+import io.modelcontextprotocol.spec.McpSchema;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.infra.openai.mock.OpenAIMock;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class OpenAIAgenticTraceTest extends CamelTestSupport {
+
+ private static final String ENDPOINT_URI =
"openai:chat-completion?model=gpt-5&apiKey=dummy"
+ +
"&autoToolExecution=true&baseUrl=%s/v1";
+
+ @RegisterExtension
+ public OpenAIMock openAIMock = new OpenAIMock().builder()
+ .when("call one tool")
+ .withUsage(10, 5)
+ .invokeTool("get_weather")
+ .withParam("city", "London")
+ .replyWith("The weather in London is sunny.")
+ .end()
+ .when("call two tools")
+ .withUsage(12, 6)
+ .invokeTool("find_location")
+ .withParam("name", "Paris")
+ .andThenInvokeTool("get_weather")
+ .withParam("latitude", "48.8566")
+ .withUsage(8, 4)
+ .replyWith("The weather in Paris is cloudy.")
+ .end()
+ .when("no tools needed")
+ .withUsage(3, 2)
+ .replyWith("Just a text response")
+ .end()
+ .when("expensive tool call")
+ .withUsage(70, 50)
+ .invokeTool("get_weather")
+ .withParam("city", "Paris")
+ .replyWith("Should not reach this response")
+ .end()
+ .when("keep calling tools")
+ .invokeTool("get_weather")
+ .withParam("city", "A")
+ .andThenInvokeTool("get_weather")
+ .withParam("city", "B")
+ .replyWith("Should not reach this response")
+ .end()
+ .build();
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:mcp-chat")
+
.toF("openai:chat-completion?model=gpt-5&apiKey=dummy&autoToolExecution=true&baseUrl=%s/v1",
+ openAIMock.getBaseUrl());
+
+ from("direct:token-budget-fail")
+
.toF("openai:chat-completion?model=gpt-5&apiKey=dummy&autoToolExecution=true"
+ +
"&maxAgenticTokens=100&maxToolIterations=5&baseUrl=%s/v1",
+ openAIMock.getBaseUrl());
+
+ from("direct:max-iterations-fail")
+
.toF("openai:chat-completion?model=gpt-5&apiKey=dummy&autoToolExecution=true"
+ + "&maxToolIterations=1&baseUrl=%s/v1",
+ openAIMock.getBaseUrl());
+ }
+ };
+ }
+
+ private McpSyncClient createMockMcpClient(String resultText) {
+ McpSyncClient client = mock(McpSyncClient.class);
+ McpSchema.CallToolResult result = McpSchema.CallToolResult.builder()
+ .content(List.of(new McpSchema.TextContent(null, resultText,
null)))
+ .isError(false)
+ .build();
+
when(client.callTool(any(McpSchema.CallToolRequest.class))).thenReturn(result);
+ return client;
+ }
+
+ private void injectMcpTools(Map<String, McpSyncClient> toolClients) {
+ injectMcpTools(String.format(ENDPOINT_URI, openAIMock.getBaseUrl()),
toolClients);
+ }
+
+ private void injectMcpTools(String endpointUri, Map<String, McpSyncClient>
toolClients) {
+ OpenAIEndpoint endpoint = context.getEndpoint(endpointUri,
OpenAIEndpoint.class);
+ List<McpSchema.Tool> mcpTools = toolClients.keySet().stream()
+ .map(name -> McpSchema.Tool.builder(name, Map.of("type",
"object"))
+ .description("Mock tool: " + name)
+ .build())
+ .toList();
+ endpoint.setMcpToolState(new McpToolState(
+ McpToolConverter.convert(mcpTools),
+ toolClients,
+ Map.of(),
+ Set.of(),
+ Map.of()));
+ }
+
+ @Test
+ void shouldExposePerIterationTraceForSingleToolBatch() {
+ Map<String, McpSyncClient> toolClients = new HashMap<>();
+ toolClients.put("get_weather", createMockMcpClient("Sunny, 22°C"));
+ injectMcpTools(toolClients);
+
+ Exchange exchange = template.request("direct:mcp-chat", e ->
e.getIn().setBody("call one tool"));
+
+ @SuppressWarnings("unchecked")
+ List<AgenticIterationTrace> trace
+ = exchange.getProperty(OpenAIConstants.AGENTIC_TRACE,
List.class);
+
+ assertThat(trace).hasSize(2);
+ assertThat(trace.get(0).iteration()).isEqualTo(1);
+ assertThat(trace.get(0).toolCalls()).hasSize(1);
+
assertThat(trace.get(0).toolCalls().get(0).toolName()).isEqualTo("get_weather");
+
assertThat(trace.get(0).toolCalls().get(0).argumentsSummary()).contains("London");
+
assertThat(trace.get(0).toolCalls().get(0).resultSummary()).contains("Sunny");
+ assertThat(trace.get(0).toolCalls().get(0).success()).isTrue();
+ assertThat(trace.get(0).promptTokens()).isEqualTo(10);
+ assertThat(trace.get(0).completionTokens()).isEqualTo(5);
+ assertThat(trace.get(1).iteration()).isEqualTo(2);
+ assertThat(trace.get(1).toolCalls()).isEmpty();
+ assertThat(trace.get(1).promptTokens()).isGreaterThan(0);
+ }
+
+ @Test
+ void shouldExposeTraceForMultiStepAgenticLoop() {
+ Map<String, McpSyncClient> toolClients = new HashMap<>();
+ toolClients.put("find_location", createMockMcpClient("48.8566,
2.3522"));
+ toolClients.put("get_weather", createMockMcpClient("Cloudy, 15°C"));
+ injectMcpTools(toolClients);
+
+ Exchange exchange = template.request("direct:mcp-chat", e ->
e.getIn().setBody("call two tools"));
+
+ @SuppressWarnings("unchecked")
+ List<AgenticIterationTrace> trace
+ = exchange.getProperty(OpenAIConstants.AGENTIC_TRACE,
List.class);
+
+ assertThat(trace).hasSizeGreaterThanOrEqualTo(3);
+
assertThat(trace.get(0).toolCalls()).extracting(AgenticToolCallTrace::toolName)
+ .containsExactly("find_location");
+
assertThat(trace.get(1).toolCalls()).extracting(AgenticToolCallTrace::toolName)
+ .containsExactly("get_weather");
+ assertThat(trace.get(trace.size() - 1).toolCalls()).isEmpty();
+ }
+
+ @Test
+ void shouldExposeTraceWhenModelReturnsDirectAnswer() {
+ Map<String, McpSyncClient> toolClients = new HashMap<>();
+ toolClients.put("get_weather", createMockMcpClient("unused"));
+ injectMcpTools(toolClients);
+
+ Exchange exchange = template.request("direct:mcp-chat", e ->
e.getIn().setBody("no tools needed"));
+
+ @SuppressWarnings("unchecked")
+ List<AgenticIterationTrace> trace
+ = exchange.getProperty(OpenAIConstants.AGENTIC_TRACE,
List.class);
+
+ assertThat(trace).hasSize(1);
+ assertThat(trace.get(0).iteration()).isEqualTo(1);
+ assertThat(trace.get(0).toolCalls()).isEmpty();
+ assertThat(trace.get(0).promptTokens()).isEqualTo(3);
+ assertThat(trace.get(0).completionTokens()).isEqualTo(2);
+ }
+
+ @Test
+ void shouldSetCumulativeTokenHeadersAlongsideTrace() {
+ Map<String, McpSyncClient> toolClients = new HashMap<>();
+ toolClients.put("get_weather", createMockMcpClient("Sunny, 22°C"));
+ injectMcpTools(toolClients);
+
+ Exchange exchange = template.request("direct:mcp-chat", e ->
e.getIn().setBody("call one tool"));
+
+
assertThat(exchange.getMessage().getHeader(OpenAIConstants.AGENTIC_PROMPT_TOKENS,
Long.class)).isPositive();
+
assertThat(exchange.getMessage().getHeader(OpenAIConstants.AGENTIC_TOTAL_TOKENS,
Long.class)).isPositive();
+
assertThat(exchange.getProperty(OpenAIConstants.AGENTIC_TRACE)).isNotNull();
+ }
+
+ @Test
+ void shouldPublishTraceWhenTokenBudgetExceeded() {
+ Map<String, McpSyncClient> toolClients = new HashMap<>();
+ toolClients.put("get_weather", createMockMcpClient("Sunny, 22°C"));
+ String endpointUri = String.format(
+
"openai:chat-completion?model=gpt-5&apiKey=dummy&autoToolExecution=true"
+ +
"&maxAgenticTokens=100&maxToolIterations=5&baseUrl=%s/v1",
+ openAIMock.getBaseUrl());
+ injectMcpTools(endpointUri, toolClients);
+
+ Exchange exchange = template.request("direct:token-budget-fail", e ->
e.getIn().setBody("expensive tool call"));
+
+
assertThat(exchange.getException()).isInstanceOf(IllegalStateException.class);
+ @SuppressWarnings("unchecked")
+ List<AgenticIterationTrace> trace
+ = exchange.getProperty(OpenAIConstants.AGENTIC_TRACE,
List.class);
+ assertThat(trace).isNotNull().hasSize(1);
+ assertThat(trace.get(0).iteration()).isEqualTo(1);
+ assertThat(trace.get(0).toolCalls()).isEmpty();
+ assertThat(trace.get(0).promptTokens()).isEqualTo(70);
+ assertThat(trace.get(0).completionTokens()).isEqualTo(50);
+ }
+
+ @Test
+ void shouldPublishTraceWhenMaxIterationsExceeded() {
+ Map<String, McpSyncClient> toolClients = new HashMap<>();
+ toolClients.put("get_weather", createMockMcpClient("Sunny, 22°C"));
+ String endpointUri = String.format(
+
"openai:chat-completion?model=gpt-5&apiKey=dummy&autoToolExecution=true"
+ +
"&maxToolIterations=1&baseUrl=%s/v1",
+ openAIMock.getBaseUrl());
+ injectMcpTools(endpointUri, toolClients);
+
+ Exchange exchange = template.request("direct:max-iterations-fail", e
-> e.getIn().setBody("keep calling tools"));
+
+
assertThat(exchange.getException()).isInstanceOf(IllegalStateException.class);
+ @SuppressWarnings("unchecked")
+ List<AgenticIterationTrace> trace
+ = exchange.getProperty(OpenAIConstants.AGENTIC_TRACE,
List.class);
+ assertThat(trace).isNotNull().isNotEmpty();
+ assertThat(trace.get(0).toolCalls()).isNotEmpty();
+ }
+}
diff --git
a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/EndpointSchemaGeneratorMojo.java
b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/EndpointSchemaGeneratorMojo.java
index 0e9a843921e9..6be3dfa1237a 100644
---
a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/EndpointSchemaGeneratorMojo.java
+++
b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/EndpointSchemaGeneratorMojo.java
@@ -409,6 +409,9 @@ public class EndpointSchemaGeneratorMojo extends
AbstractGeneratorMojo {
}
return false;
}
+ if (metadata.skip()) {
+ return false;
+ }
final String[] applicableFor = metadata.applicableFor();
if (applicableFor.length > 0 &&
Arrays.stream(applicableFor).noneMatch(s -> s.equals(scheme))) {
if (getLog().isDebugEnabled()) {