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()) {

Reply via email to