This is an automated email from the ASF dual-hosted git repository.

oscerd 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 b5a64d3cc981 CAMEL-25408: camel-api, camel-openai, camel-ai-tool - a 
common AiEvent API and an Ai event type (#27714)
b5a64d3cc981 is described below

commit b5a64d3cc981b1d78ef4db5e047a3263c0331279
Author: Andrea Cosentino <[email protected]>
AuthorDate: Tue Oct 13 10:35:22 2026 +0200

    CAMEL-25408: camel-api, camel-openai, camel-ai-tool - a common AiEvent API 
and an Ai event type (#27714)
    
    Adds a common AI event contract to camel-api: org.apache.camel.ai.AiEvent 
(with the
    exchange-backed AiEvent.ExchangeAiEvent sub-tree) and a new 
CamelEvent.Type.Ai, appended
    last so existing ordinals are unchanged. The camel-openai agentic loop/tool 
events and the
    camel-ai-tool AiToolAuthorizationDeniedEvent move off Type.Custom onto the 
new contract.
    
    AI events are delivered through EventHelper.notifyAiEvent like every other 
category:
    started notifiers only, lazy event creation, a new ignoreAiEvents flag 
(default method on
    EventNotifier, real flag on the support class and exposed over JMX), and 
per-notifier
    failure isolation. The debugger, the event console and the 
Micrometer/OpenTelemetry exchange
    notifiers opt out by default; camel-event can subscribe to the Ai type. The 
4.24 upgrade
    guide documents the Type.Custom -> Type.Ai change for events that shipped 
in 4.23.0.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    Signed-off-by: Andrea Cosentino <[email protected]>
---
 .../camel/catalog/docs/ai-tool-component.adoc      |  11 +-
 .../apache/camel/catalog/docs/event-component.adoc |   3 +
 .../org/apache/camel/catalog/docs/openai-mcp.adoc  |  10 +-
 .../src/main/docs/ai-tool-component.adoc           |  11 +-
 .../ai/tool/AiToolAuthorizationDeniedEvent.java    |  24 ++--
 .../camel/component/ai/tool/AiToolExecutor.java    |  21 +--
 .../tool/AiToolAuthorizationDeniedEventTest.java   | 151 ++++++++++++++++++++-
 .../camel-openai/src/main/docs/openai-mcp.adoc     |  10 +-
 .../openai/AbstractOpenAIExchangeEvent.java        |  23 ++--
 .../openai/OpenAIAgenticLoopCompletedEvent.java    |   7 +-
 .../openai/OpenAIAgenticLoopStartedEvent.java      |   6 +-
 .../openai/OpenAIAgenticObservability.java         |  27 ++--
 .../openai/OpenAIAgenticToolCallExecutedEvent.java |   7 +-
 .../openai/OpenAIAgenticEventNotifierTest.java     |   8 +-
 .../openai/OpenAIResponsesAgenticTest.java         |   4 +-
 .../camel-event/src/main/docs/event-component.adoc |   3 +
 .../component/camelevent/CamelEventConsumer.java   |   5 +
 .../MicrometerExchangeEventNotifier.java           |   3 +
 .../OpenTelemetryExchangeEventNotifier.java        |   4 +-
 .../src/main/java/org/apache/camel/ai/AiEvent.java | 121 +++++++++++++++++
 .../main/java/org/apache/camel/spi/CamelEvent.java |   9 +-
 .../java/org/apache/camel/spi/EventNotifier.java   |  19 +++
 .../camel/impl/debugger/DefaultDebugger.java       |   3 +
 .../apache/camel/impl/console/EventConsole.java    |   6 +
 .../management/mbean/ManagedEventNotifier.java     |  14 +-
 .../mbean/ManagedEventNotifierMBean.java           |   4 +
 .../java/org/apache/camel/support/EventHelper.java |  54 ++++++++
 .../camel/support/SimpleEventNotifierSupport.java  |  12 ++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  31 +++++
 29 files changed, 523 insertions(+), 88 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
index b5b934eb1a23..2ddc6ff82265 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
@@ -455,19 +455,20 @@ Because the guard runs in front of the route, a denied 
call is caught before the
 exchange-lifecycle event or route span. Every denial is instead:
 
 * logged at `WARN`, and
-* published as an 
`org.apache.camel.component.ai.tool.AiToolAuthorizationDeniedEvent` — a 
`CamelEvent` of type
-  `Custom` carrying the denied tool name, the tool exchange (for correlation), 
and the `CamelAuthorizationException`
-  the policy raised.
+* published as an 
`org.apache.camel.component.ai.tool.AiToolAuthorizationDeniedEvent` — an
+  `org.apache.camel.ai.AiEvent.ToolCallDeniedEvent` (a `CamelEvent` of type 
`Ai`) carrying the denied tool name, the
+  tool exchange (for correlation), and the `CamelAuthorizationException` the 
policy raised.
 
 Register an `EventNotifier` to count, log or alert on denials — for example to 
drive a micrometer counter — without
-changing what the model sees (still the short refusal):
+changing what the model sees (still the short refusal). Match on the shared 
`AiEvent.ToolCallDeniedEvent` interface so
+the notifier does not depend on this component:
 
 [source,java]
 ----
 context.getManagementStrategy().addEventNotifier(new EventNotifierSupport() {
     @Override
     public void notify(CamelEvent event) {
-        if (event instanceof AiToolAuthorizationDeniedEvent denied) {
+        if (event instanceof AiEvent.ToolCallDeniedEvent denied) {
             meterRegistry.counter("ai.tool.authorization.denied", "tool", 
denied.getToolName()).increment();
         }
     }
diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/event-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/event-component.adoc
index 5998ab19869b..abe6cbf96274 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/event-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/event-component.adoc
@@ -107,6 +107,9 @@ The following event types are available (corresponding to 
`org.apache.camel.spi.
 - `ServiceStartupFailure`
 - `ServiceStopFailure`
 
+=== AI Events
+- `Ai` (events fired by Camel AI components, such as tool calls and agentic 
loops; see `org.apache.camel.ai.AiEvent`)
+
 == Wildcard Subscriptions
 
 Instead of listing each event type individually, you can use wildcard patterns 
with a `*` suffix
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 70084fa6472b..c83b78c34099 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
@@ -494,11 +494,13 @@ breakdown: iteration number, tool calls (name, truncated 
arguments and results,
 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:
+`org.apache.camel.ai.AiEvent` instances (a `CamelEvent` of type `Ai`) are also 
emitted during the agentic loop for
+EventNotifier listeners, so a notifier can recognise them — and route them to 
metrics or tracing — by their shared
+interface, without depending on this component:
 
-* `OpenAIAgenticLoopStartedEvent` — tool count and max iterations
-* `OpenAIAgenticToolCallExecutedEvent` — tool name, duration, success per tool 
call
-* `OpenAIAgenticLoopCompletedEvent` — iteration count, cumulative tokens, stop 
reason
+* `OpenAIAgenticLoopStartedEvent` (an `AiEvent.AgentLoopStartedEvent`) — tool 
count and max iterations
+* `OpenAIAgenticToolCallExecutedEvent` (an `AiEvent.ToolCallExecutedEvent`) — 
tool name, duration, success per tool call
+* `OpenAIAgenticLoopCompletedEvent` (an `AiEvent.AgentLoopCompletedEvent`) — 
iteration count, cumulative tokens, stop reason
 
 These events provide a lightweight integration point for future OpenTelemetry 
GenAI instrumentation.
 
diff --git 
a/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc 
b/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc
index b5b934eb1a23..2ddc6ff82265 100644
--- a/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc
+++ b/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc
@@ -455,19 +455,20 @@ Because the guard runs in front of the route, a denied 
call is caught before the
 exchange-lifecycle event or route span. Every denial is instead:
 
 * logged at `WARN`, and
-* published as an 
`org.apache.camel.component.ai.tool.AiToolAuthorizationDeniedEvent` — a 
`CamelEvent` of type
-  `Custom` carrying the denied tool name, the tool exchange (for correlation), 
and the `CamelAuthorizationException`
-  the policy raised.
+* published as an 
`org.apache.camel.component.ai.tool.AiToolAuthorizationDeniedEvent` — an
+  `org.apache.camel.ai.AiEvent.ToolCallDeniedEvent` (a `CamelEvent` of type 
`Ai`) carrying the denied tool name, the
+  tool exchange (for correlation), and the `CamelAuthorizationException` the 
policy raised.
 
 Register an `EventNotifier` to count, log or alert on denials — for example to 
drive a micrometer counter — without
-changing what the model sees (still the short refusal):
+changing what the model sees (still the short refusal). Match on the shared 
`AiEvent.ToolCallDeniedEvent` interface so
+the notifier does not depend on this component:
 
 [source,java]
 ----
 context.getManagementStrategy().addEventNotifier(new EventNotifierSupport() {
     @Override
     public void notify(CamelEvent event) {
-        if (event instanceof AiToolAuthorizationDeniedEvent denied) {
+        if (event instanceof AiEvent.ToolCallDeniedEvent denied) {
             meterRegistry.counter("ai.tool.authorization.denied", "tool", 
denied.getToolName()).increment();
         }
     }
diff --git 
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolAuthorizationDeniedEvent.java
 
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolAuthorizationDeniedEvent.java
index 09e5e4bfe1a7..3fbfd4c82743 100644
--- 
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolAuthorizationDeniedEvent.java
+++ 
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolAuthorizationDeniedEvent.java
@@ -21,7 +21,7 @@ import java.util.EventObject;
 
 import org.apache.camel.CamelAuthorizationException;
 import org.apache.camel.Exchange;
-import org.apache.camel.spi.CamelEvent;
+import org.apache.camel.ai.AiEvent;
 
 /**
  * Fired when an {@code ai-tool} route's {@code authorizationPolicy} denies a 
tool call.
@@ -32,14 +32,14 @@ import org.apache.camel.spi.CamelEvent;
  * alert on authorization denials, without changing what the model sees — the 
call is still relayed back to the model as
  * a short refusal.
  * <p>
- * It is a {@link CamelEvent.Type#Custom} event and deliberately not a {@link 
CamelEvent.ExchangeEvent}, so it does not
- * pollute generic exchange lifecycle metrics; {@link #getExchange()} is 
public so listeners can still correlate it with
- * the tool exchange (route id, exchange id). It is a {@link 
CamelEvent.FailureEvent}, so {@link #getCause()} returns
- * the {@link CamelAuthorizationException} the policy raised.
+ * It is an {@link AiEvent.ToolCallDeniedEvent} (of {@link 
org.apache.camel.spi.CamelEvent.Type#Ai}), so a notifier can
+ * recognise it as AI activity without depending on this component; {@link 
#getExchange()} correlates it with the tool
+ * exchange (route id, exchange id), and {@link #getCause()} returns the 
{@link CamelAuthorizationException} the policy
+ * raised.
  *
  * @since 4.23
  */
-public final class AiToolAuthorizationDeniedEvent extends EventObject 
implements CamelEvent.FailureEvent {
+public final class AiToolAuthorizationDeniedEvent extends EventObject 
implements AiEvent.ToolCallDeniedEvent {
 
     @Serial
     private static final long serialVersionUID = 1L;
@@ -56,9 +56,15 @@ public final class AiToolAuthorizationDeniedEvent extends 
EventObject implements
         this.cause = cause;
     }
 
+    @Override
+    public String getProvider() {
+        return "ai-tool";
+    }
+
     /**
      * The tool exchange whose call was denied (for correlation, e.g. route id 
or exchange id).
      */
+    @Override
     public Exchange getExchange() {
         return exchange;
     }
@@ -66,6 +72,7 @@ public final class AiToolAuthorizationDeniedEvent extends 
EventObject implements
     /**
      * The name (route id) of the tool whose call was denied.
      */
+    @Override
     public String getToolName() {
         return toolName;
     }
@@ -75,11 +82,6 @@ public final class AiToolAuthorizationDeniedEvent extends 
EventObject implements
         return cause;
     }
 
-    @Override
-    public Type getType() {
-        return Type.Custom;
-    }
-
     @Override
     public long getTimestamp() {
         return timestamp;
diff --git 
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java
 
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java
index 028d667f0a2b..c86858e66283 100644
--- 
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java
+++ 
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java
@@ -24,8 +24,8 @@ import java.util.Set;
 import org.apache.camel.CamelAuthorizationException;
 import org.apache.camel.Exchange;
 import org.apache.camel.Processor;
-import org.apache.camel.spi.ManagementStrategy;
 import org.apache.camel.support.DefaultConsumer;
+import org.apache.camel.support.EventHelper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -199,20 +199,11 @@ public final class AiToolExecutor {
      * and a failure to notify is swallowed so it never affects the refusal 
returned to the model.
      */
     private static void fireAuthorizationDeniedEvent(Exchange exchange, String 
toolName, CamelAuthorizationException denial) {
-        ManagementStrategy management = 
exchange.getContext().getManagementStrategy();
-        if (management.getEventNotifiers().isEmpty()) {
-            return;
-        }
-        try {
-            management.notify(new AiToolAuthorizationDeniedEvent(exchange, 
toolName, denial));
-        } catch (Throwable e) {
-            // ManagementStrategy.notify() does not isolate a failing notifier 
(unlike EventHelper.doNotifyEvent), so a
-            // notifier throwing anything - including an Error such as 
AssertionError - would otherwise escape and turn
-            // the denial into a propagating failure. Swallow it here (as 
EventHelper does) so a broken notifier never
-            // changes the AuthorizationDenied refusal returned to the model.
-            LOG.warn("Notifying {} for tool '{}' failed and was ignored; the 
authorization denial is unaffected",
-                    AiToolAuthorizationDeniedEvent.class.getSimpleName(), 
toolName, e);
-        }
+        // Route through EventHelper like every other event category: it skips 
notifiers with ignoreAiEvents set,
+        // creates the event lazily, and isolates a failing notifier (its 
doNotifyEvent swallows a Throwable) - so a
+        // broken notifier never turns the denial into a propagating failure 
or changes the refusal to the model.
+        EventHelper.notifyAiEvent(exchange.getContext(),
+                () -> new AiToolAuthorizationDeniedEvent(exchange, toolName, 
denial));
     }
 
     private static CamelAuthorizationException 
findAuthorizationException(Throwable error) {
diff --git 
a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolAuthorizationDeniedEventTest.java
 
b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolAuthorizationDeniedEventTest.java
index 888555f93849..ba4be732bd21 100644
--- 
a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolAuthorizationDeniedEventTest.java
+++ 
b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolAuthorizationDeniedEventTest.java
@@ -25,11 +25,13 @@ import org.apache.camel.Exchange;
 import org.apache.camel.NamedNode;
 import org.apache.camel.Processor;
 import org.apache.camel.Route;
+import org.apache.camel.ai.AiEvent;
 import org.apache.camel.builder.RouteBuilder;
 import org.apache.camel.spi.AuthorizationPolicy;
 import org.apache.camel.spi.CamelEvent;
 import org.apache.camel.spi.Registry;
 import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.support.EventHelper;
 import org.apache.camel.support.EventNotifierSupport;
 import org.apache.camel.test.junit6.CamelTestSupport;
 import org.junit.jupiter.api.BeforeEach;
@@ -140,10 +142,40 @@ class AiToolAuthorizationDeniedEventTest extends 
CamelTestSupport {
         // ...and the denial is now observable as a single event
         assertThat(events).hasSize(1);
         AiToolAuthorizationDeniedEvent event = events.get(0);
-        assertThat(event.getType()).isEqualTo(CamelEvent.Type.Custom);
+        assertThat(event.getType()).isEqualTo(CamelEvent.Type.Ai);
         assertThat(event.getToolName()).isEqualTo("guarded");
         assertThat(event.getExchange()).isSameAs(exchange);
         
assertThat(event.getCause()).isInstanceOf(CamelAuthorizationException.class);
+        // recognisable through the shared contract, without a dependency on 
this component
+        assertThat(event).isInstanceOf(AiEvent.ToolCallDeniedEvent.class);
+        assertThat(event.getProvider()).isEqualTo("ai-tool");
+    }
+
+    @Test
+    void aNotifierThatIgnoresAiEventsIsSkipped() {
+        // EventHelper.notifyAiEvent skips notifiers with ignoreAiEvents set, 
while the default notifier from
+        // @BeforeEach (ignoreAiEvents=false) still receives the event
+        List<AiEvent> ignored = new CopyOnWriteArrayList<>();
+        EventNotifierSupport ignoring = new EventNotifierSupport() {
+            @Override
+            public void notify(CamelEvent event) {
+                if (event instanceof AiEvent ai) {
+                    ignored.add(ai);
+                }
+            }
+        };
+        ignoring.setIgnoreAiEvents(true);
+        context.getManagementStrategy().addEventNotifier(ignoring);
+
+        AiToolSpec spec = findSpec("guarded");
+        Exchange exchange = new DefaultExchange(context);
+        exchange.setProperty("subject", "mallory");
+
+        AiToolResult result = AiToolExecutor.execute(spec, Map.of(), exchange);
+
+        
assertThat(result).isInstanceOf(AiToolResult.AuthorizationDenied.class);
+        assertThat(events).hasSize(1);
+        assertThat(ignored).isEmpty();
     }
 
     @Test
@@ -180,14 +212,52 @@ class AiToolAuthorizationDeniedEventTest extends 
CamelTestSupport {
     }
 
     @Test
-    void aNotifierThrowingAnErrorDoesNotBreakTheRefusal() {
-        // ManagementStrategy.notify does not isolate a failing notifier, so 
without a Throwable guard a notifier that
-        // throws an Error would escape and turn the denial into a propagating 
failure. The refusal must survive.
+    void aFailingNotifierNeitherBreaksTheRefusalNorStopsALaterSubscriber() {
+        // Each notifier must be isolated across ALL its callbacks: a notifier 
that throws from the AI eligibility check
+        // (isIgnoreAiEvents, called in EventHelper.notifyAiEvent) or from 
isEnabled/notify (called in doNotifyEvent)
+        // must neither escape - turning the denial into a propagating failure 
- nor stop delivery to a subscriber
+        // registered AFTER it. Registering the healthy collector last is what 
exercises the continuation.
+        // (isDisabled is the other eligibility check my guard wraps, but it 
is also called unguarded by the generic
+        // lifecycle paths such as context stop, so throwing there would only 
break teardown; isIgnoreAiEvents is the
+        // AI-specific one that exercises this fix cleanly.)
+        context.getManagementStrategy().addEventNotifier(new 
EventNotifierSupport() {
+            @Override
+            public boolean isIgnoreAiEvents() {
+                throw new AssertionError("broken isIgnoreAiEvents");
+            }
+
+            @Override
+            public void notify(CamelEvent event) {
+            }
+        });
+        context.getManagementStrategy().addEventNotifier(new 
EventNotifierSupport() {
+            @Override
+            public boolean isEnabled(CamelEvent event) {
+                if (event instanceof AiEvent) {
+                    throw new AssertionError("broken isEnabled");
+                }
+                return true;
+            }
+
+            @Override
+            public void notify(CamelEvent event) {
+            }
+        });
+        context.getManagementStrategy().addEventNotifier(new 
EventNotifierSupport() {
+            @Override
+            public void notify(CamelEvent event) {
+                if (event instanceof AiEvent) {
+                    throw new AssertionError("broken notify");
+                }
+            }
+        });
+        // a healthy subscriber registered AFTER the failing ones
+        List<AiEvent> later = new CopyOnWriteArrayList<>();
         context.getManagementStrategy().addEventNotifier(new 
EventNotifierSupport() {
             @Override
             public void notify(CamelEvent event) {
-                if (event instanceof AiToolAuthorizationDeniedEvent) {
-                    throw new AssertionError("broken notifier");
+                if (event instanceof AiEvent ai) {
+                    later.add(ai);
                 }
             }
         });
@@ -199,6 +269,75 @@ class AiToolAuthorizationDeniedEventTest extends 
CamelTestSupport {
         AiToolResult result = AiToolExecutor.execute(spec, Map.of(), exchange);
 
         
assertThat(result).isInstanceOf(AiToolResult.AuthorizationDenied.class);
+        // the subscriber registered after the failing notifiers still 
received the event
+        assertThat(later).hasSize(1);
+    }
+
+    @Test
+    void aiIsItsOwnCategoryAndIgnoreExchangeEventsDoesNotDropIt() {
+        // a notifier with ignoreExchangeEvents=true (but 
ignoreAiEvents=false) must still receive AI events, so an
+        // AI-observability notifier can keep ignoreExchangeEvents for 
performance
+        List<AiEvent> exchangeIgnorer = new CopyOnWriteArrayList<>();
+        EventNotifierSupport n = new EventNotifierSupport() {
+            @Override
+            public void notify(CamelEvent event) {
+                if (event instanceof AiEvent ai) {
+                    exchangeIgnorer.add(ai);
+                }
+            }
+        };
+        n.setIgnoreExchangeEvents(true);
+        context.getManagementStrategy().addEventNotifier(n);
+
+        AiToolSpec spec = findSpec("guarded");
+        Exchange exchange = new DefaultExchange(context);
+        exchange.setProperty("subject", "mallory");
+
+        AiToolExecutor.execute(spec, Map.of(), exchange);
+
+        assertThat(exchangeIgnorer).hasSize(1);
+    }
+
+    @Test
+    void dispatchesAnAiEventThatHasNoExchange() {
+        // the root AiEvent does not require a live Exchange; the helper must 
deliver an Exchange-free AiEvent
+        List<AiEvent> received = new CopyOnWriteArrayList<>();
+        context.getManagementStrategy().addEventNotifier(new 
EventNotifierSupport() {
+            @Override
+            public void notify(CamelEvent event) {
+                if (event instanceof AiEvent ai) {
+                    received.add(ai);
+                }
+            }
+        });
+
+        AiEvent exchangeFree = new AiEvent() {
+            @Override
+            public String getProvider() {
+                return "unit";
+            }
+
+            @Override
+            public Object getSource() {
+                return "unit";
+            }
+
+            @Override
+            public long getTimestamp() {
+                return 0;
+            }
+
+            @Override
+            public void setTimestamp(long timestamp) {
+            }
+        };
+
+        boolean notified = EventHelper.notifyAiEvent(context, () -> 
exchangeFree);
+
+        assertThat(notified).isTrue();
+        assertThat(received).containsExactly(exchangeFree);
+        
assertThat(exchangeFree).isNotInstanceOf(CamelEvent.ExchangeEvent.class);
+        assertThat(exchangeFree.getType()).isEqualTo(CamelEvent.Type.Ai);
     }
 
     private AiToolSpec findSpec(String toolName) {
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 70084fa6472b..c83b78c34099 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
@@ -494,11 +494,13 @@ breakdown: iteration number, tool calls (name, truncated 
arguments and results,
 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:
+`org.apache.camel.ai.AiEvent` instances (a `CamelEvent` of type `Ai`) are also 
emitted during the agentic loop for
+EventNotifier listeners, so a notifier can recognise them — and route them to 
metrics or tracing — by their shared
+interface, without depending on this component:
 
-* `OpenAIAgenticLoopStartedEvent` — tool count and max iterations
-* `OpenAIAgenticToolCallExecutedEvent` — tool name, duration, success per tool 
call
-* `OpenAIAgenticLoopCompletedEvent` — iteration count, cumulative tokens, stop 
reason
+* `OpenAIAgenticLoopStartedEvent` (an `AiEvent.AgentLoopStartedEvent`) — tool 
count and max iterations
+* `OpenAIAgenticToolCallExecutedEvent` (an `AiEvent.ToolCallExecutedEvent`) — 
tool name, duration, success per tool call
+* `OpenAIAgenticLoopCompletedEvent` (an `AiEvent.AgentLoopCompletedEvent`) — 
iteration count, cumulative tokens, stop reason
 
 These events provide a lightweight integration point for future OpenTelemetry 
GenAI instrumentation.
 
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
index 9e51ada65c9e..7cfd41183119 100644
--- 
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
@@ -20,16 +20,16 @@ import java.io.Serial;
 import java.util.EventObject;
 
 import org.apache.camel.Exchange;
-import org.apache.camel.spi.CamelEvent;
+import org.apache.camel.ai.AiEvent;
 
 /**
- * Base class for OpenAI agentic loop {@link CamelEvent} notifications.
+ * Base class for OpenAI agentic loop {@link AiEvent} 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}.
+ * These are {@link org.apache.camel.spi.CamelEvent.Type#Ai} events carrying 
the exchange they relate to, so an
+ * {@code EventNotifier} can recognise them as AI activity and correlate them 
(route id, exchange id) without depending
+ * on this component.
  */
-abstract class AbstractOpenAIExchangeEvent extends EventObject implements 
CamelEvent {
+abstract class AbstractOpenAIExchangeEvent extends EventObject implements 
AiEvent.ExchangeAiEvent {
 
     @Serial
     private static final long serialVersionUID = 1L;
@@ -42,6 +42,12 @@ abstract class AbstractOpenAIExchangeEvent extends 
EventObject implements CamelE
         this.exchange = exchange;
     }
 
+    @Override
+    public String getProvider() {
+        return "openai";
+    }
+
+    @Override
     public Exchange getExchange() {
         return exchange;
     }
@@ -55,9 +61,4 @@ abstract class AbstractOpenAIExchangeEvent extends 
EventObject implements CamelE
     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/OpenAIAgenticLoopCompletedEvent.java
 
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticLoopCompletedEvent.java
index 8fc4de329dd9..2e99e58b5778 100644
--- 
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
@@ -19,13 +19,15 @@ package org.apache.camel.component.openai;
 import java.io.Serial;
 
 import org.apache.camel.Exchange;
+import org.apache.camel.ai.AiEvent;
 
 /**
  * Fired when an OpenAI MCP agentic loop completes on an exchange.
  *
  * @since 4.23
  */
-public final class OpenAIAgenticLoopCompletedEvent extends 
AbstractOpenAIExchangeEvent {
+public final class OpenAIAgenticLoopCompletedEvent extends 
AbstractOpenAIExchangeEvent
+        implements AiEvent.AgentLoopCompletedEvent {
 
     @Serial
     private static final long serialVersionUID = 1L;
@@ -42,14 +44,17 @@ public final class OpenAIAgenticLoopCompletedEvent extends 
AbstractOpenAIExchang
         this.stopReason = stopReason;
     }
 
+    @Override
     public int getIterationCount() {
         return iterationCount;
     }
 
+    @Override
     public long getTotalTokens() {
         return totalTokens;
     }
 
+    @Override
     public String getStopReason() {
         return stopReason;
     }
diff --git 
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticLoopStartedEvent.java
 
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticLoopStartedEvent.java
index 5042fbb16f78..597fa127aba5 100644
--- 
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticLoopStartedEvent.java
+++ 
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticLoopStartedEvent.java
@@ -19,13 +19,15 @@ package org.apache.camel.component.openai;
 import java.io.Serial;
 
 import org.apache.camel.Exchange;
+import org.apache.camel.ai.AiEvent;
 
 /**
  * Fired when an OpenAI MCP agentic loop starts on an exchange.
  *
  * @since 4.23
  */
-public final class OpenAIAgenticLoopStartedEvent extends 
AbstractOpenAIExchangeEvent {
+public final class OpenAIAgenticLoopStartedEvent extends 
AbstractOpenAIExchangeEvent
+        implements AiEvent.AgentLoopStartedEvent {
 
     @Serial
     private static final long serialVersionUID = 1L;
@@ -39,10 +41,12 @@ public final class OpenAIAgenticLoopStartedEvent extends 
AbstractOpenAIExchangeE
         this.maxIterations = maxIterations;
     }
 
+    @Override
     public int getToolCount() {
         return toolCount;
     }
 
+    @Override
     public int getMaxIterations() {
         return 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
index c2e2ba2cdb2e..b30fc4a4d72d 100644
--- 
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
@@ -19,10 +19,12 @@ package org.apache.camel.component.openai;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
 
 import com.openai.models.chat.completions.ChatCompletionMessageToolCall;
 import org.apache.camel.Exchange;
-import org.apache.camel.spi.CamelEvent;
+import org.apache.camel.ai.AiEvent;
+import org.apache.camel.support.EventHelper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -49,18 +51,18 @@ final class OpenAIAgenticObservability {
 
     void onLoopStarted(int toolCount, int maxIterations) {
         loopStarted = true;
-        notify(new OpenAIAgenticLoopStartedEvent(exchange, toolCount, 
maxIterations));
+        notify(() -> new OpenAIAgenticLoopStartedEvent(exchange, toolCount, 
maxIterations));
     }
 
     void onLoopCompleted(int iterationCount, OpenAIAgenticTokenTracker 
tokenTracker, String stopReason) {
         loopCompleted = true;
-        notify(new OpenAIAgenticLoopCompletedEvent(
-                exchange, iterationCount, tokenTracker.getTotalTokens(), 
stopReason));
+        long totalTokens = tokenTracker.getTotalTokens();
+        notify(() -> new OpenAIAgenticLoopCompletedEvent(exchange, 
iterationCount, totalTokens, stopReason));
         publishTrace();
     }
 
     void onToolCallExecuted(int iteration, McpToolCallExecutor.ToolResult 
result) {
-        notify(new OpenAIAgenticToolCallExecutedEvent(
+        notify(() -> new OpenAIAgenticToolCallExecutedEvent(
                 exchange, iteration, result.toolName(), result.durationMs(), 
result.success()));
     }
 
@@ -121,16 +123,9 @@ final class OpenAIAgenticObservability {
         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);
-            }
-        }
+    private void notify(Supplier<AiEvent> eventSupplier) {
+        // route through EventHelper like every other event category: it skips 
notifiers with ignoreAiEvents set,
+        // builds the event lazily (only when a notifier wants it) and 
isolates a failing notifier
+        EventHelper.notifyAiEvent(exchange.getContext(), eventSupplier);
     }
 }
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
index 10f433eca0ed..01597c8a4549 100644
--- 
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
@@ -19,13 +19,15 @@ package org.apache.camel.component.openai;
 import java.io.Serial;
 
 import org.apache.camel.Exchange;
+import org.apache.camel.ai.AiEvent;
 
 /**
  * Fired after a single tool call completes during an OpenAI MCP agentic loop.
  *
  * @since 4.23
  */
-public final class OpenAIAgenticToolCallExecutedEvent extends 
AbstractOpenAIExchangeEvent {
+public final class OpenAIAgenticToolCallExecutedEvent extends 
AbstractOpenAIExchangeEvent
+        implements AiEvent.ToolCallExecutedEvent {
 
     @Serial
     private static final long serialVersionUID = 1L;
@@ -49,14 +51,17 @@ public final class OpenAIAgenticToolCallExecutedEvent 
extends AbstractOpenAIExch
         return iteration;
     }
 
+    @Override
     public String getToolName() {
         return toolName;
     }
 
+    @Override
     public long getDurationMs() {
         return durationMs;
     }
 
+    @Override
     public boolean isSuccess() {
         return success;
     }
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
index c9810103995a..30e5944765cf 100644
--- 
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
@@ -26,6 +26,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
 import io.modelcontextprotocol.client.McpSyncClient;
 import io.modelcontextprotocol.spec.McpSchema;
 import org.apache.camel.Exchange;
+import org.apache.camel.ai.AiEvent;
 import org.apache.camel.builder.RouteBuilder;
 import org.apache.camel.spi.CamelEvent;
 import org.apache.camel.support.EventNotifierSupport;
@@ -85,14 +86,14 @@ class OpenAIAgenticEventNotifierTest extends 
CamelTestSupport {
         context.getManagementStrategy().addEventNotifier(new 
EventNotifierSupport() {
             @Override
             public void notify(CamelEvent event) {
-                if (event.getType() == CamelEvent.Type.Custom) {
+                if (event.getType() == CamelEvent.Type.Ai) {
                     events.add(event);
                 }
             }
 
             @Override
             public boolean isEnabled(CamelEvent event) {
-                return event.getType() == CamelEvent.Type.Custom;
+                return event.getType() == CamelEvent.Type.Ai;
             }
         });
     }
@@ -153,6 +154,9 @@ class OpenAIAgenticEventNotifierTest extends 
CamelTestSupport {
         assertThat(toolEvents.get(0).getToolName()).isEqualTo("get_weather");
         assertThat(toolEvents.get(0).getIteration()).isEqualTo(1);
         
assertThat(toolEvents.get(0).getDurationMs()).isGreaterThanOrEqualTo(0);
+        // recognisable through the shared contract, without a dependency on 
camel-openai
+        
assertThat(toolEvents.get(0)).isInstanceOf(AiEvent.ToolCallExecutedEvent.class);
+        assertThat(toolEvents.get(0).getProvider()).isEqualTo("openai");
         assertThat(completed).hasSize(1);
         assertThat(completed.get(0).getIterationCount()).isEqualTo(1);
         assertThat(completed.get(0).getTotalTokens()).isPositive();
diff --git 
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIResponsesAgenticTest.java
 
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIResponsesAgenticTest.java
index ab28ca94defd..371321304870 100644
--- 
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIResponsesAgenticTest.java
+++ 
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIResponsesAgenticTest.java
@@ -109,14 +109,14 @@ class OpenAIResponsesAgenticTest extends CamelTestSupport 
{
         context.getManagementStrategy().addEventNotifier(new 
EventNotifierSupport() {
             @Override
             public void notify(CamelEvent event) {
-                if (event.getType() == CamelEvent.Type.Custom) {
+                if (event.getType() == CamelEvent.Type.Ai) {
                     events.add(event);
                 }
             }
 
             @Override
             public boolean isEnabled(CamelEvent event) {
-                return event.getType() == CamelEvent.Type.Custom;
+                return event.getType() == CamelEvent.Type.Ai;
             }
         });
     }
diff --git a/components/camel-event/src/main/docs/event-component.adoc 
b/components/camel-event/src/main/docs/event-component.adoc
index 5998ab19869b..abe6cbf96274 100644
--- a/components/camel-event/src/main/docs/event-component.adoc
+++ b/components/camel-event/src/main/docs/event-component.adoc
@@ -107,6 +107,9 @@ The following event types are available (corresponding to 
`org.apache.camel.spi.
 - `ServiceStartupFailure`
 - `ServiceStopFailure`
 
+=== AI Events
+- `Ai` (events fired by Camel AI components, such as tool calls and agentic 
loops; see `org.apache.camel.ai.AiEvent`)
+
 == Wildcard Subscriptions
 
 Instead of listing each event type individually, you can use wildcard patterns 
with a `*` suffix
diff --git 
a/components/camel-event/src/main/java/org/apache/camel/component/camelevent/CamelEventConsumer.java
 
b/components/camel-event/src/main/java/org/apache/camel/component/camelevent/CamelEventConsumer.java
index 0623eb16a773..6bbd33f47277 100644
--- 
a/components/camel-event/src/main/java/org/apache/camel/component/camelevent/CamelEventConsumer.java
+++ 
b/components/camel-event/src/main/java/org/apache/camel/component/camelevent/CamelEventConsumer.java
@@ -228,6 +228,7 @@ public class CamelEventConsumer extends DefaultConsumer {
         eventNotifier.setIgnoreExchangeSendingEvents(true);
         eventNotifier.setIgnoreExchangeSentEvents(true);
         eventNotifier.setIgnoreStepEvents(true);
+        eventNotifier.setIgnoreAiEvents(true);
 
         // Enable only the categories that contain the requested event types
         for (CamelEvent.Type type : types) {
@@ -309,6 +310,10 @@ public class CamelEventConsumer extends DefaultConsumer {
                     // Custom events are CamelContextEvents, so we need to 
enable context events
                     eventNotifier.setIgnoreCamelContextEvents(false);
                     break;
+                case Ai:
+                    // AI events are their own category (see 
org.apache.camel.ai.AiEvent)
+                    eventNotifier.setIgnoreAiEvents(false);
+                    break;
                 default:
                     break;
             }
diff --git 
a/components/camel-micrometer/src/main/java/org/apache/camel/component/micrometer/eventnotifier/MicrometerExchangeEventNotifier.java
 
b/components/camel-micrometer/src/main/java/org/apache/camel/component/micrometer/eventnotifier/MicrometerExchangeEventNotifier.java
index 01939f952477..a73e8c6249f8 100644
--- 
a/components/camel-micrometer/src/main/java/org/apache/camel/component/micrometer/eventnotifier/MicrometerExchangeEventNotifier.java
+++ 
b/components/camel-micrometer/src/main/java/org/apache/camel/component/micrometer/eventnotifier/MicrometerExchangeEventNotifier.java
@@ -55,6 +55,9 @@ public class MicrometerExchangeEventNotifier extends 
AbstractMicrometerEventNoti
 
     public MicrometerExchangeEventNotifier() {
         super(ExchangeEvent.class);
+        // AI events are ExchangeEvents but a specialized category; skip them 
so each one does not trigger a route
+        // lookup here (they are not metered as exchange lifecycle events 
anyway)
+        setIgnoreAiEvents(true);
     }
 
     public void setBaseEndpointURI(boolean baseEndpointURI) {
diff --git 
a/components/camel-opentelemetry-metrics/src/main/java/org/apache/camel/opentelemetry/metrics/eventnotifier/OpenTelemetryExchangeEventNotifier.java
 
b/components/camel-opentelemetry-metrics/src/main/java/org/apache/camel/opentelemetry/metrics/eventnotifier/OpenTelemetryExchangeEventNotifier.java
index 53e787cdec12..625aacfbb9f6 100644
--- 
a/components/camel-opentelemetry-metrics/src/main/java/org/apache/camel/opentelemetry/metrics/eventnotifier/OpenTelemetryExchangeEventNotifier.java
+++ 
b/components/camel-opentelemetry-metrics/src/main/java/org/apache/camel/opentelemetry/metrics/eventnotifier/OpenTelemetryExchangeEventNotifier.java
@@ -68,7 +68,9 @@ public class OpenTelemetryExchangeEventNotifier extends 
EventNotifierSupport {
     private LongHistogram sentTimer;
 
     public OpenTelemetryExchangeEventNotifier() {
-        // no-op
+        // AI events are ExchangeEvents but a specialized category; skip them 
so each one does not trigger a route
+        // lookup here (they are not metered as exchange lifecycle events 
anyway)
+        setIgnoreAiEvents(true);
     }
 
     // Use the base endpoint to avoid increasing the number of separate events 
on dynamic endpoints (ie, toD).
diff --git a/core/camel-api/src/main/java/org/apache/camel/ai/AiEvent.java 
b/core/camel-api/src/main/java/org/apache/camel/ai/AiEvent.java
new file mode 100644
index 000000000000..9c8ba82d8080
--- /dev/null
+++ b/core/camel-api/src/main/java/org/apache/camel/ai/AiEvent.java
@@ -0,0 +1,121 @@
+/*
+ * 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.ai;
+
+import org.apache.camel.spi.CamelEvent;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Common contract for events fired by Camel AI components (tool calls, 
agentic loops, ...).
+ * <p>
+ * An {@code AiEvent} is typed {@link CamelEvent.Type#Ai} rather than {@link 
CamelEvent.Type#Custom}, so an
+ * {@link org.apache.camel.spi.EventNotifier} can recognise AI activity — and 
route it to metrics, tracing or a
+ * GenAI-observability backend — by type and by these sub-interfaces, without 
depending on any AI component or doing an
+ * {@code instanceof} against each component's own event class.
+ * <p>
+ * The root does <em>not</em> require a live {@link 
org.apache.camel.Exchange}: an event that relates to one implements
+ * {@link ExchangeAiEvent}, while an event that has no single exchange (for 
example a detached evaluation or a
+ * population-level signal) implements {@code AiEvent} directly. Concrete 
event classes live in the AI components (they
+ * implement these interfaces); the only thing shared across components is 
this contract.
+ *
+ * @since 4.24
+ */
+public interface AiEvent extends CamelEvent {
+
+    /**
+     * The integration or provider family publishing the event, e.g. {@code 
openai}, {@code ai-tool}, or
+     * {@code semantic}. Intended as a stable, low-cardinality label for 
metrics and spans; not an expert instance,
+     * model revision, invocation ID, or an indication that the event 
necessarily involved inference.
+     */
+    String getProvider();
+
+    @Override
+    default Type getType() {
+        return Type.Ai;
+    }
+
+    /**
+     * An {@link AiEvent} that relates to a specific {@link 
org.apache.camel.Exchange}, so it can be correlated by route
+     * id or exchange id. AI events without a single exchange implement {@link 
AiEvent} directly instead.
+     *
+     * @since 4.24
+     */
+    interface ExchangeAiEvent extends AiEvent, CamelEvent.ExchangeEvent {
+    }
+
+    /**
+     * Fired when a tool call is denied by the route's {@code 
authorizationPolicy}. Also a
+     * {@link CamelEvent.FailureEvent}, so {@link #getCause()} returns the 
{@code CamelAuthorizationException} the
+     * policy raised.
+     *
+     * @since 4.24
+     */
+    interface ToolCallDeniedEvent extends ExchangeAiEvent, 
CamelEvent.FailureEvent {
+
+        /** The name (route id) of the tool whose call was denied. */
+        String getToolName();
+    }
+
+    /**
+     * Fired after a single tool call has been executed.
+     *
+     * @since 4.24
+     */
+    interface ToolCallExecutedEvent extends ExchangeAiEvent {
+
+        /** The name of the tool that was called. */
+        String getToolName();
+
+        /** Whether the tool call completed successfully. */
+        boolean isSuccess();
+
+        /** How long the tool call took, in milliseconds. */
+        long getDurationMs();
+    }
+
+    /**
+     * Fired when an agentic loop starts.
+     *
+     * @since 4.24
+     */
+    interface AgentLoopStartedEvent extends ExchangeAiEvent {
+
+        /** The number of tools available to the loop. */
+        int getToolCount();
+
+        /** The maximum number of iterations the loop may run. */
+        int getMaxIterations();
+    }
+
+    /**
+     * Fired when an agentic loop completes.
+     *
+     * @since 4.24
+     */
+    interface AgentLoopCompletedEvent extends ExchangeAiEvent {
+
+        /** The number of iterations the loop actually ran. */
+        int getIterationCount();
+
+        /** The total number of tokens the loop consumed, or {@code 0} when 
not tracked. */
+        long getTotalTokens();
+
+        /** Why the loop stopped (for example {@code completed} or {@code 
max-iterations}), when known. */
+        @Nullable
+        String getStopReason();
+    }
+}
diff --git a/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java 
b/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java
index 821755ee5f6f..d0ec06c13663 100644
--- a/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java
+++ b/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java
@@ -81,7 +81,14 @@ public interface CamelEvent {
         StepStarted,
         StepCompleted,
         StepFailed,
-        Custom
+        Custom,
+        /**
+         * Events fired by Camel AI components (tool calls, agentic loops, 
...); see
+         * {@link org.apache.camel.ai.AiEvent}. Appended last so the ordinals 
of the pre-existing values are unchanged.
+         *
+         * @since 4.24
+         */
+        Ai
     }
 
     /** Returns the type of this event. */
diff --git 
a/core/camel-api/src/main/java/org/apache/camel/spi/EventNotifier.java 
b/core/camel-api/src/main/java/org/apache/camel/spi/EventNotifier.java
index 21edf65d9e65..bd4d522fe937 100644
--- a/core/camel-api/src/main/java/org/apache/camel/spi/EventNotifier.java
+++ b/core/camel-api/src/main/java/org/apache/camel/spi/EventNotifier.java
@@ -133,6 +133,25 @@ public interface EventNotifier {
     /** Set whether to ignore step events. */
     void setIgnoreStepEvents(boolean ignoreStepEvents);
 
+    /**
+     * Whether to ignore AI events (see {@link org.apache.camel.ai.AiEvent}). 
AI events are their own category and are
+     * not affected by {@link #isIgnoreExchangeEvents()}, even for the 
exchange-backed
+     * {@link org.apache.camel.ai.AiEvent.ExchangeAiEvent} subtype.
+     *
+     * @since 4.24
+     */
+    default boolean isIgnoreAiEvents() {
+        return false;
+    }
+
+    /**
+     * Set whether to ignore AI events (see {@link 
org.apache.camel.ai.AiEvent}).
+     *
+     * @since 4.24
+     */
+    default void setIgnoreAiEvents(boolean ignoreAiEvents) {
+    }
+
     /** @deprecated Set whether to ignore exchange async processing started 
events. */
     @Deprecated(since = "4.19.0")
     void setIgnoreExchangeAsyncProcessingStartedEvents(boolean 
ignoreExchangeAsyncProcessingStartedEvents);
diff --git 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/debugger/DefaultDebugger.java
 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/debugger/DefaultDebugger.java
index 9488d1bc0805..98fbcf33b688 100644
--- 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/debugger/DefaultDebugger.java
+++ 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/debugger/DefaultDebugger.java
@@ -397,6 +397,9 @@ public class DefaultDebugger extends ServiceSupport 
implements Debugger {
         private DebugEventNotifier() {
             setIgnoreCamelContextEvents(true);
             setIgnoreServiceEvents(true);
+            // AI events are ExchangeEvents but are a specialized category; 
the debugger must not forward them to
+            // breakpoints (it blind-casts every event to ExchangeEvent)
+            setIgnoreAiEvents(true);
         }
 
         @Override
diff --git 
a/core/camel-console/src/main/java/org/apache/camel/impl/console/EventConsole.java
 
b/core/camel-console/src/main/java/org/apache/camel/impl/console/EventConsole.java
index a41590aac968..4ad12524ade3 100644
--- 
a/core/camel-console/src/main/java/org/apache/camel/impl/console/EventConsole.java
+++ 
b/core/camel-console/src/main/java/org/apache/camel/impl/console/EventConsole.java
@@ -189,6 +189,12 @@ public class EventConsole extends AbstractDevConsole {
 
     private class ConsoleEventNotifier extends EventNotifierSupport implements 
NonManagedService {
 
+        private ConsoleEventNotifier() {
+            // AI events are ExchangeEvents but a specialized category; keep 
them out of the exchange-event ring so
+            // they do not push out real exchange events
+            setIgnoreAiEvents(true);
+        }
+
         @Override
         public void notify(CamelEvent event) throws Exception {
             if (event instanceof CamelEvent.ExchangeEvent) {
diff --git 
a/core/camel-management/src/main/java/org/apache/camel/management/mbean/ManagedEventNotifier.java
 
b/core/camel-management/src/main/java/org/apache/camel/management/mbean/ManagedEventNotifier.java
index e8383d672990..3dca086437e9 100644
--- 
a/core/camel-management/src/main/java/org/apache/camel/management/mbean/ManagedEventNotifier.java
+++ 
b/core/camel-management/src/main/java/org/apache/camel/management/mbean/ManagedEventNotifier.java
@@ -163,13 +163,23 @@ public class ManagedEventNotifier extends 
NotificationBroadcasterSupport impleme
         getEventNotifier().setIgnoreStepEvents(ignoreStepEvents);
     }
 
+    @Override
+    public boolean isIgnoreAiEvents() {
+        return getEventNotifier().isIgnoreAiEvents();
+    }
+
+    @Override
+    public void setIgnoreAiEvents(boolean ignoreAiEvents) {
+        getEventNotifier().setIgnoreAiEvents(ignoreAiEvents);
+    }
+
     @Override
     public MBeanNotificationInfo[] getNotificationInfo() {
         // JmxNotificationEventNotifier uses the simple class name of the 
event as the notification type
         List<MBeanNotificationInfo> infos = new ArrayList<>();
         for (CamelEvent.Type type : CamelEvent.Type.values()) {
-            if (type == CamelEvent.Type.Custom) {
-                // custom events have their own class names
+            if (type == CamelEvent.Type.Custom || type == CamelEvent.Type.Ai) {
+                // custom and AI events have their own class names
                 continue;
             }
             String name = type.name();
diff --git 
a/core/camel-management/src/main/java/org/apache/camel/management/mbean/ManagedEventNotifierMBean.java
 
b/core/camel-management/src/main/java/org/apache/camel/management/mbean/ManagedEventNotifierMBean.java
index aaf84b037768..7712c1c3ce90 100644
--- 
a/core/camel-management/src/main/java/org/apache/camel/management/mbean/ManagedEventNotifierMBean.java
+++ 
b/core/camel-management/src/main/java/org/apache/camel/management/mbean/ManagedEventNotifierMBean.java
@@ -62,4 +62,8 @@ public interface ManagedEventNotifierMBean {
 
     void setIgnoreStepEvents(boolean ignoreStepEvents);
 
+    boolean isIgnoreAiEvents();
+
+    void setIgnoreAiEvents(boolean ignoreAiEvents);
+
 }
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/EventHelper.java 
b/core/camel-support/src/main/java/org/apache/camel/support/EventHelper.java
index 2bdf7445f861..b4e597256f17 100644
--- a/core/camel-support/src/main/java/org/apache/camel/support/EventHelper.java
+++ b/core/camel-support/src/main/java/org/apache/camel/support/EventHelper.java
@@ -18,12 +18,14 @@ package org.apache.camel.support;
 
 import java.util.List;
 import java.util.function.BiFunction;
+import java.util.function.Supplier;
 
 import org.apache.camel.CamelContext;
 import org.apache.camel.Endpoint;
 import org.apache.camel.Exchange;
 import org.apache.camel.Processor;
 import org.apache.camel.Route;
+import org.apache.camel.ai.AiEvent;
 import org.apache.camel.spi.CamelEvent;
 import org.apache.camel.spi.EventFactory;
 import org.apache.camel.spi.EventNotifier;
@@ -1574,6 +1576,58 @@ public final class EventHelper {
         return answer;
     }
 
+    /**
+     * Notifies an {@link AiEvent} to the started event notifiers, following 
the same path as every other event
+     * category: it loops the started notifiers, skips those that are disabled 
or have {@code ignoreAiEvents} set,
+     * creates the event lazily (via {@code eventSupplier}) only when some 
notifier wants it, and delivers through
+     * {@code doNotifyEvent} so one failing notifier does not stop delivery to 
the others.
+     * <p>
+     * AI events are their own category: {@code ignoreExchangeEvents} does 
<em>not</em> suppress them, even for the
+     * exchange-backed {@link AiEvent.ExchangeAiEvent} subtype. A notifier 
opts out with
+     * {@code setIgnoreAiEvents(true)}.
+     *
+     * @since 4.24
+     */
+    public static boolean notifyAiEvent(CamelContext context, 
Supplier<AiEvent> eventSupplier) {
+        ManagementStrategy management = context != null ? 
context.getManagementStrategy() : null;
+        if (management == null) {
+            return false;
+        }
+
+        List<EventNotifier> notifiers = management.getStartedEventNotifiers();
+        if (notifiers == null || notifiers.isEmpty()) {
+            return false;
+        }
+
+        boolean answer = false;
+        CamelEvent event = null;
+        // optimise for loop using index access to avoid creating iterator 
object
+        for (int i = 0; i < notifiers.size(); i++) {
+            EventNotifier notifier = notifiers.get(i);
+            // isDisabled()/isIgnoreAiEvents() are overridable callbacks, so 
guard them per notifier too (not only the
+            // isEnabled()/notify() calls inside doNotifyEvent): a broken 
notifier must neither escape - which would
+            // change the producer's outcome, e.g. an authorization refusal - 
nor stop delivery to the other notifiers.
+            try {
+                if (notifier.isDisabled() || notifier.isIgnoreAiEvents()) {
+                    continue;
+                }
+
+                if (event == null) {
+                    // only create event once, and only when a notifier wants 
it
+                    event = eventSupplier.get();
+                    if (event == null) {
+                        return answer;
+                    }
+                }
+                answer |= doNotifyEvent(notifier, event);
+            } catch (Throwable e) {
+                LOG.warn("Error notifying AI event {}. This exception will be 
ignored.",
+                        event != null ? event : "<not created>", e);
+            }
+        }
+        return answer;
+    }
+
     private static boolean isDisabledOrIgnored(EventNotifier notifier) {
         return notifier.isDisabled() || notifier.isIgnoreExchangeEvents();
     }
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/SimpleEventNotifierSupport.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/SimpleEventNotifierSupport.java
index 848c593155fd..3c6bb1f2c9bb 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/support/SimpleEventNotifierSupport.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/SimpleEventNotifierSupport.java
@@ -45,6 +45,8 @@ public abstract class SimpleEventNotifierSupport extends 
ServiceSupport implemen
     boolean ignoreExchangeSendingEvents;
     boolean ignoreExchangeSentEvents;
     boolean ignoreStepEvents;
+    // AI events are their own category (not part of setupIgnore), so 
ignoreExchangeEvents does not drop them
+    boolean ignoreAiEvents;
 
     protected SimpleEventNotifierSupport() {
         setupIgnore(true);
@@ -191,6 +193,16 @@ public abstract class SimpleEventNotifierSupport extends 
ServiceSupport implemen
         this.ignoreStepEvents = ignoreStepEvents;
     }
 
+    @Override
+    public boolean isIgnoreAiEvents() {
+        return ignoreAiEvents;
+    }
+
+    @Override
+    public void setIgnoreAiEvents(boolean ignoreAiEvents) {
+        this.ignoreAiEvents = ignoreAiEvents;
+    }
+
     @Override
     @Deprecated(since = "4.19.0")
     public boolean isIgnoreExchangeAsyncProcessingStartedEvents() {
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 01f65789c818..2dd3b6c01525 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -74,6 +74,37 @@ This also applies to the LRAs the service starts itself, 
which a nested saga rou
 coordinator returns LRA URLs under another base URL than `coordinatorUrl`, for 
example behind a proxy, add that
 base URL to `allowedCoordinatorUrls` as well.
 
+=== camel-openai, camel-ai-tool - AI events now use CamelEvent.Type.Ai
+
+The events fired by the AI components now share a common contract, 
`org.apache.camel.ai.AiEvent` (a
+`CamelEvent` of the new `CamelEvent.Type.Ai`), instead of each being a 
component-specific
+`CamelEvent.Type.Custom` event. Events that relate to a single exchange 
implement the exchange-backed subtype
+`AiEvent.ExchangeAiEvent` (the root `AiEvent` itself is not an 
`ExchangeEvent`). This lets an `EventNotifier` recognise AI activity — and 
route it to metrics or
+tracing — by type and by the `AiEvent` sub-interfaces, without depending on an 
AI component.
+
+The events shipped in 4.23.0 are affected:
+
+* `camel-ai-tool`: `AiToolAuthorizationDeniedEvent` now implements 
`AiEvent.ToolCallDeniedEvent`;
+* `camel-openai`: `OpenAIAgenticLoopStartedEvent`, 
`OpenAIAgenticToolCallExecutedEvent` and
+  `OpenAIAgenticLoopCompletedEvent` now implement 
`AiEvent.AgentLoopStartedEvent`, `AiEvent.ToolCallExecutedEvent`
+  and `AiEvent.AgentLoopCompletedEvent`.
+
+Their `getType()` now returns `CamelEvent.Type.Ai` rather than 
`CamelEvent.Type.Custom`, and they are now
+`CamelEvent.ExchangeEvent`s (via `AiEvent.ExchangeAiEvent`). An 
`EventNotifier` that recognised these events by
+`event.getType() == CamelEvent.Type.Custom` must match on `CamelEvent.Type.Ai` 
instead, or — preferably —
+on `event instanceof org.apache.camel.ai.AiEvent` (or one of its 
sub-interfaces), which no longer requires a
+dependency on the AI component. The concrete event classes and all their 
accessors are unchanged, so code that
+references them directly is not affected.
+
+Because the shipped AI events are now `ExchangeEvent`s (via 
`AiEvent.ExchangeAiEvent`), a notifier that filters on
+`instanceof ExchangeEvent` now also receives them. AI events are their own 
category: a new `ignoreAiEvents` flag
+(`EventNotifier.setIgnoreAiEvents(boolean)`, default `false`) opts a notifier 
out, and `ignoreExchangeEvents` does
+**not** drop AI events (so an AI-observability notifier can keep 
`ignoreExchangeEvents=true` for performance). Camel's
+own exchange-oriented notifiers (the debugger, the dev-console event ring, the 
micrometer and OpenTelemetry metrics
+exchange notifiers) set
+`ignoreAiEvents=true` so AI events do not reach them; a custom 
exchange-focused notifier that should not see AI events
+should do the same.
+
 === camel-report-maven-plugin - Java routes are read with the Java DSL parser
 
 The `validate` and `route-coverage` goals read Java routes with the Java DSL 
parser of camel-java-io instead of

Reply via email to