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