Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,15 @@
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.ReentrantLock;

import org.apache.camel.CamelContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* CamelContext-scoped registry mapping tags to {@link AiToolSpec} instances. AI components (LangChain4j, Spring AI)
Expand All @@ -32,18 +36,24 @@
* Each {@link CamelContext} gets its own registry instance, registered as a context plugin. Use
* {@link #getOrCreate(CamelContext)} to obtain the instance for a given context.
* <p>
* Adapters that need to react to tools appearing or disappearing (e.g. to push MCP {@code tools/list_changed}
* notifications) can register an {@link AiToolRegistryListener} instead of polling.
* <p>
* Replaces the duplicated {@code CamelToolExecutorCache} singletons from {@code camel-langchain4j-tools} and
* {@code camel-spring-ai-tools}.
*
* @since 4.22
*/
public final class AiToolRegistry {

private static final Logger LOG = LoggerFactory.getLogger(AiToolRegistry.class);

private static final ReentrantLock FACTORY_LOCK = new ReentrantLock();

private final ReentrantLock lock = new ReentrantLock();
private final Map<String, Set<AiToolSpec>> tools;
private final Set<AiToolSpec> defaultTools;
private final List<AiToolRegistryListener> listeners = new CopyOnWriteArrayList<>();

AiToolRegistry() {
tools = new HashMap<>();
Expand Down Expand Up @@ -71,6 +81,7 @@ public static AiToolRegistry getOrCreate(CamelContext context) {
}

public void put(String tag, AiToolSpec spec) {
boolean added;
lock.lock();
try {
Set<AiToolSpec> set = tools.computeIfAbsent(tag, k -> new LinkedHashSet<>());
Expand All @@ -81,28 +92,36 @@ public void put(String tag, AiToolSpec spec) {
+ "': tool names must be unique per tag");
}
}
set.add(spec);
added = set.add(spec);
} finally {
lock.unlock();
}
if (added) {
notifyRegistered(tag, spec);
}
}

public void remove(String tag, AiToolSpec spec) {
boolean removed = false;
lock.lock();
try {
Set<AiToolSpec> set = tools.get(tag);
if (set != null) {
set.remove(spec);
removed = set.remove(spec);
if (set.isEmpty()) {
tools.remove(tag);
}
}
} finally {
lock.unlock();
}
if (removed) {
notifyDeregistered(tag, spec);
}
}

public void putDefault(AiToolSpec spec) {
boolean added;
lock.lock();
try {
for (AiToolSpec existing : defaultTools) {
Expand All @@ -112,19 +131,60 @@ public void putDefault(AiToolSpec spec) {
+ "' in the default pool: tool names must be unique");
}
}
defaultTools.add(spec);
added = defaultTools.add(spec);
} finally {
lock.unlock();
}
if (added) {
notifyRegistered(null, spec);
}
}

public void removeDefault(AiToolSpec spec) {
boolean removed;
lock.lock();
try {
defaultTools.remove(spec);
removed = defaultTools.remove(spec);
} finally {
lock.unlock();
}
if (removed) {
notifyDeregistered(null, spec);
}
}

/**
* Adds a listener notified on tool registration changes. See {@link AiToolRegistryListener} for the callback
* contract and the subscribe-then-snapshot idiom to observe current state without missing events.
*/
public void addListener(AiToolRegistryListener listener) {
listeners.add(listener);
}

public void removeListener(AiToolRegistryListener listener) {
listeners.remove(listener);
}

private void notifyRegistered(String tag, AiToolSpec spec) {
for (AiToolRegistryListener listener : listeners) {
try {
listener.toolRegistered(tag, spec);
} catch (Exception e) {
LOG.warn("AiToolRegistryListener {} failed on toolRegistered for tool '{}': {}",
listener, spec.getName(), e.getMessage(), e);
}
}
}

private void notifyDeregistered(String tag, AiToolSpec spec) {
for (AiToolRegistryListener listener : listeners) {
try {
listener.toolDeregistered(tag, spec);
} catch (Exception e) {
LOG.warn("AiToolRegistryListener {} failed on toolDeregistered for tool '{}': {}",
listener, spec.getName(), e.getMessage(), e);
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.camel.component.ai.tool;

/**
* Listener notified when tool specifications are registered to or deregistered from an {@link AiToolRegistry}.
* <p>
* Registration events are driven by the {@code ai-tool} consumer lifecycle: a tool is registered when its route starts
* or resumes, and deregistered when its route stops or suspends. A tool endpoint declaring multiple tags fires one
* event per tag.
* <p>
* Callbacks are invoked outside the registry lock, on the thread performing the (de)registration — typically a route
* lifecycle thread. Implementations must be thread-safe and non-blocking; a callback that throws is logged and does not
* affect the registration itself or other listeners.
* <p>
* To observe the current registry state without missing concurrent changes, add the listener first and then read a
* snapshot (e.g. {@link AiToolRegistry#getTools()}), tolerating events that duplicate snapshot content.
*
* @since 4.22
*/
public interface AiToolRegistryListener {

/**
* Called after a tool specification has been registered.
*
* @param tag the tag the tool was registered under, or {@code null} for the default (untagged) pool
* @param spec the registered tool specification
*/
void toolRegistered(String tag, AiToolSpec spec);

/**
* Called after a tool specification has been deregistered.
*
* @param tag the tag the tool was deregistered from, or {@code null} for the default (untagged) pool
* @param spec the deregistered tool specification
*/
void toolDeregistered(String tag, AiToolSpec spec);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.camel.component.ai.tool;

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

import org.apache.camel.CamelContext;
import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.test.junit6.CamelTestSupport;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;

/**
* Verifies that {@link AiToolRegistryListener} callbacks are driven by the {@code ai-tool} consumer lifecycle: route
* start/resume registers, route stop/suspend deregisters.
*/
class AiToolRegistryListenerLifecycleTest extends CamelTestSupport {

private final RecordingListener listener = new RecordingListener();

@Override
protected CamelContext createCamelContext() throws Exception {
CamelContext camelContext = super.createCamelContext();
AiToolRegistry.getOrCreate(camelContext).addListener(listener);
return camelContext;
}

@Override
protected RouteBuilder createRouteBuilder() {
return new RouteBuilder() {
public void configure() {
from("ai-tool:getWeather?tags=weather&description=Get the weather")
.routeId("weather-route")
.setBody(constant("sunny"));
}
};
}

@Test
void testEventsOnRouteStartAndStop() throws Exception {
assertThat(listener.events)
.as("Route start should fire toolRegistered")
.extracting(Event::type, Event::tag, Event::toolName)
.containsExactly(tuple("registered", "weather", "getWeather"));

context.getRouteController().stopRoute("weather-route");

assertThat(listener.events)
.extracting(Event::type, Event::tag, Event::toolName)
.containsExactly(
tuple("registered", "weather", "getWeather"),
tuple("deregistered", "weather", "getWeather"));
}

@Test
void testEventsOnSuspendAndResume() throws Exception {
context.getRouteController().suspendRoute("weather-route");
context.getRouteController().resumeRoute("weather-route");

assertThat(listener.events)
.extracting(Event::type, Event::tag, Event::toolName)
.containsExactly(
tuple("registered", "weather", "getWeather"),
tuple("deregistered", "weather", "getWeather"),
tuple("registered", "weather", "getWeather"));
}

@Test
void testMultiTagEndpointFiresOneEventPerTag() throws Exception {
context.addRoutes(new RouteBuilder() {
public void configure() {
from("ai-tool:sendEmail?tags=notify,crm&description=Send an email")
.routeId("email-route")
.setBody(constant("sent"));
}
});

assertThat(listener.events)
.filteredOn(e -> "sendEmail".equals(e.toolName()))
.extracting(Event::type, Event::tag)
.containsExactlyInAnyOrder(
tuple("registered", "notify"),
tuple("registered", "crm"));
}

@Test
void testUntaggedEndpointFiresDefaultPoolEvent() throws Exception {
context.addRoutes(new RouteBuilder() {
public void configure() {
from("ai-tool:lookupOrder?description=Look up an order")
.routeId("order-route")
.setBody(constant("order"));
}
});

assertThat(listener.events)
.filteredOn(e -> "lookupOrder".equals(e.toolName()))
.extracting(Event::type, Event::tag)
.containsExactly(tuple("registered", null));

context.getRouteController().stopRoute("order-route");

assertThat(listener.events)
.filteredOn(e -> "lookupOrder".equals(e.toolName()))
.extracting(Event::type, Event::tag)
.containsExactly(
tuple("registered", null),
tuple("deregistered", null));
}

private record Event(String type, String tag, String toolName) {
}

private static final class RecordingListener implements AiToolRegistryListener {
private final List<Event> events = new CopyOnWriteArrayList<>();

@Override
public void toolRegistered(String tag, AiToolSpec spec) {
events.add(new Event("registered", tag, spec.getName()));
}

@Override
public void toolDeregistered(String tag, AiToolSpec spec) {
events.add(new Event("deregistered", tag, spec.getName()));
}
}
}
Loading