diff --git a/docs/modules/ROOT/pages/reference/extensions/ai-tool.adoc b/docs/modules/ROOT/pages/reference/extensions/ai-tool.adoc index 16920abb9a4e..10120444692e 100644 --- a/docs/modules/ROOT/pages/reference/extensions/ai-tool.adoc +++ b/docs/modules/ROOT/pages/reference/extensions/ai-tool.adoc @@ -92,3 +92,59 @@ from("ai-tool:greet?description=Greet a user" .setBody(simple("Hello, ${header.name}!")); ---- +[id="extensions-ai-tool-usage-langchain4j-integration"] +=== LangChain4j integration + +When both `camel-quarkus-ai-tool` and a Quarkus LangChain4j extension (`quarkus-langchain4j-ollama`, `quarkus-langchain4j-openai`, etc.) are on the classpath, a `ToolProvider` CDI bean is registered automatically at build time. +All routes registered via `ai-tool:` endpoints become available to `@RegisterAiService` AI services without explicit wiring. + +You must also add `camel-langchain4j-agent` to your project dependencies. This artifact provides the `AiToolSpec` to `ToolSpecification` conversion and is not brought in transitively. + +[source,xml] +---- + + org.apache.camel + camel-langchain4j-agent + +---- + +With both dependencies in place, a `@RegisterAiService` interface can call Camel tools directly: + +[source,java] +---- +@RegisterAiService +public interface WeatherAiService { + String chat(@UserMessage String question); +} +---- + +Any `ai-tool:` route is now callable by the LLM behind `WeatherAiService`. No `toolProvider` attribute or manual bean reference is needed. + +[id="extensions-ai-tool-usage-tag-filtering-with-camelaitools"] +==== Tag filtering with `@CamelAiTools` + +By default, all registered `ai-tool:` routes are visible to every AI service. When you need different AI services to see different subsets of tools, annotate the service interface with `@CamelAiTools` and specify a tag value that matches the `tags` parameter on your `ai-tool:` routes. + +[source,java] +---- +@RegisterAiService +@CamelAiTools("weather") +public interface WeatherAgent { + String chat(@UserMessage String question); +} + +@RegisterAiService +@CamelAiTools("support") +public interface SupportAgent { + String chat(@UserMessage String question); +} +---- + +With this setup: + +* `WeatherAgent` sees only tools tagged with `weather` (plus any untagged tools from the default pool). +* `SupportAgent` sees only tools tagged with `support` (plus any untagged tools from the default pool). +* A `@RegisterAiService` without `@CamelAiTools` sees all registered tools regardless of tags. + +NOTE: The `@CamelAiTools` value must match a tag declared in the `ai-tool:` endpoint URI (e.g. `from("ai-tool:getWeather?tags=weather&...")`). If the annotation value is blank or does not match any route tag, the AI service will only see untagged tools. + diff --git a/extensions-support/langchain4j/deployment/pom.xml b/extensions-support/langchain4j/deployment/pom.xml index 34dc90609648..d0e48ad94c6f 100644 --- a/extensions-support/langchain4j/deployment/pom.xml +++ b/extensions-support/langchain4j/deployment/pom.xml @@ -42,6 +42,11 @@ org.apache.camel.quarkus camel-quarkus-support-langchain4j + + org.apache.camel + camel-ai-tool + true + diff --git a/extensions-support/langchain4j/deployment/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/deployment/AiToolPresent.java b/extensions-support/langchain4j/deployment/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/deployment/AiToolPresent.java new file mode 100644 index 000000000000..7168ec2d43da --- /dev/null +++ b/extensions-support/langchain4j/deployment/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/deployment/AiToolPresent.java @@ -0,0 +1,36 @@ +/* + * 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.quarkus.component.support.langchain4j.deployment; + +import java.util.function.BooleanSupplier; + +public class AiToolPresent implements BooleanSupplier { + private static final String AI_TOOL_REGISTRY_CLASS = "org.apache.camel.component.ai.tool.AiToolRegistry"; + private static final String AI_TOOL_SPEC_CONVERTER_CLASS = "org.apache.camel.component.langchain4j.agent.AiToolSpecToLangChain4j"; + + @Override + public boolean getAsBoolean() { + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + try { + cl.loadClass(AI_TOOL_REGISTRY_CLASS); + cl.loadClass(AI_TOOL_SPEC_CONVERTER_CLASS); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } +} diff --git a/extensions-support/langchain4j/deployment/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/deployment/SupportQuarkusLangchain4jProcessor.java b/extensions-support/langchain4j/deployment/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/deployment/SupportQuarkusLangchain4jProcessor.java index 79283e86939e..8dcabb9aaa80 100644 --- a/extensions-support/langchain4j/deployment/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/deployment/SupportQuarkusLangchain4jProcessor.java +++ b/extensions-support/langchain4j/deployment/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/deployment/SupportQuarkusLangchain4jProcessor.java @@ -16,13 +16,19 @@ */ package org.apache.camel.quarkus.component.support.langchain4j.deployment; +import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import dev.langchain4j.guardrail.Guardrail; import dev.langchain4j.guardrail.InputGuardrail; import dev.langchain4j.guardrail.OutputGuardrail; +import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.arc.deployment.GeneratedBeanBuildItem; +import io.quarkus.arc.deployment.GeneratedBeanGizmoAdaptor; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; import io.quarkus.arc.deployment.UnremovableBeanBuildItem; import io.quarkus.deployment.annotations.BuildProducer; @@ -34,7 +40,14 @@ import io.quarkus.deployment.builditem.SystemPropertyBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.pkg.steps.NativeOrNativeSourcesBuild; +import io.quarkus.gizmo.ClassCreator; +import io.quarkus.gizmo.MethodCreator; +import io.quarkus.gizmo.MethodDescriptor; +import io.quarkus.gizmo.ResultHandle; import jakarta.inject.Singleton; +import org.apache.camel.quarkus.component.support.langchain4j.AiToolSpecConverter; +import org.apache.camel.quarkus.component.support.langchain4j.CamelAiToolProvider; +import org.apache.camel.quarkus.component.support.langchain4j.CamelAiToolsInterceptor; import org.apache.camel.quarkus.component.support.langchain4j.QuarkusLangchain4jRecorder; import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.AnnotationTarget; @@ -53,6 +66,8 @@ class SupportQuarkusLangchain4jProcessor { public static final DotName REGISTER_AI_SERVICES_DOTNAME = DotName .createSimple("io.quarkiverse.langchain4j.RegisterAiService"); + private static final DotName CAMEL_AI_TOOLS_DOTNAME = DotName + .createSimple("org.apache.camel.quarkus.component.support.langchain4j.CamelAiTools"); private static final Logger LOG = Logger.getLogger(SupportQuarkusLangchain4jProcessor.class); @@ -127,10 +142,104 @@ void registerQuarkusLangchain4jNativeSupport( .build()); } + @BuildStep(onlyIf = AiToolPresent.class) + AdditionalBeanBuildItem registerCamelAiToolProvider() { + LOG.info("Camel AI Tool detected - registering CamelAiToolProvider as CDI bean for ToolProvider auto-discovery"); + return AdditionalBeanBuildItem.unremovableOf(CamelAiToolProvider.class); + } + + @BuildStep(onlyIf = AiToolPresent.class) + void generateAiToolSpecConverter(BuildProducer generatedBeans, + BuildProducer unremovableBeans) { + + String generatedClassName = "org.apache.camel.quarkus.component.support.langchain4j.AiToolSpecConverterImpl"; + + try (ClassCreator cc = ClassCreator.builder() + .classOutput(new GeneratedBeanGizmoAdaptor(generatedBeans)) + .className(generatedClassName) + .interfaces(AiToolSpecConverter.class) + .build()) { + + cc.addAnnotation(Singleton.class); + + String toolSpecClass = "dev.langchain4j.agent.tool.ToolSpecification"; + String aiToolSpecClass = "org.apache.camel.component.ai.tool.AiToolSpec"; + + try (MethodCreator mc = cc.getMethodCreator("toToolSpecification", + toolSpecClass, aiToolSpecClass)) { + + ResultHandle spec = mc.getMethodParam(0); + ResultHandle result = mc.invokeStaticMethod( + MethodDescriptor.ofMethod( + "org.apache.camel.component.langchain4j.agent.AiToolSpecToLangChain4j", + "toToolSpecification", + toolSpecClass, + aiToolSpecClass), + spec); + mc.returnValue(result); + } + } + + unremovableBeans.produce(beanClassNames(generatedClassName)); + } + + @BuildStep(onlyIf = AiToolPresent.class) + @Record(ExecutionTime.STATIC_INIT) + void configureCamelAiToolTags( + CombinedIndexBuildItem combinedIndex, + BuildProducer additionalBeans, + QuarkusLangchain4jRecorder recorder) { + + IndexView index = combinedIndex.getIndex(); + Map tagMap = new HashMap<>(); + for (AnnotationInstance annotation : index.getAnnotations(CAMEL_AI_TOOLS_DOTNAME)) { + if (annotation.target().kind() == AnnotationTarget.Kind.CLASS) { + String className = annotation.target().asClass().name().toString(); + if (annotation.value() == null) { + LOG.warnf("@CamelAiTools on %s has no value — skipping", className); + continue; + } + String tagValue = annotation.value().asString(); + if (tagValue.isBlank()) { + LOG.warnf("@CamelAiTools on %s has blank value — skipping", className); + continue; + } + tagMap.put(className, tagValue); + LOG.infof("Discovered @CamelAiTools(\"%s\") on %s", tagValue, className); + } + } + + if (tagMap.isEmpty()) { + return; + } + + recorder.setCamelAiToolTagMap(tagMap); + + additionalBeans.produce(AdditionalBeanBuildItem.builder() + .addBeanClasses(CamelAiToolsInterceptor.class) + .setUnremovable() + .build()); + } + @BuildStep - void markAiServicesAsUnremovable( + void validateAndRegisterAiServices( CombinedIndexBuildItem indexBuildItem, BuildProducer unremovableBeans) { + + if (!new AiToolPresent().getAsBoolean()) { + Collection aiToolsAnnotations = indexBuildItem.getIndex() + .getAnnotations(CAMEL_AI_TOOLS_DOTNAME); + if (!aiToolsAnnotations.isEmpty()) { + LOG.warnf("@CamelAiTools annotations found but camel-langchain4j-agent is not on the classpath. " + + "Add camel-langchain4j-agent dependency to enable the Camel AI tool bridge. " + + "Affected classes: %s", + aiToolsAnnotations.stream() + .filter(a -> a.target().kind() == AnnotationTarget.Kind.CLASS) + .map(a -> a.target().asClass().name().toString()) + .collect(Collectors.joining(", "))); + } + } + LOG.debug("Discovering classes annotated with @RegisterAiService to mark implementation beans as unremovable"); for (AnnotationInstance instance : indexBuildItem.getIndex().getAnnotations(REGISTER_AI_SERVICES_DOTNAME)) { diff --git a/extensions-support/langchain4j/runtime/pom.xml b/extensions-support/langchain4j/runtime/pom.xml index 85b61f8a6ede..9214158d857e 100644 --- a/extensions-support/langchain4j/runtime/pom.xml +++ b/extensions-support/langchain4j/runtime/pom.xml @@ -50,6 +50,16 @@ dev.langchain4j langchain4j-embeddings + + org.apache.camel + camel-ai-tool + true + + + org.apache.camel + camel-langchain4j-agent + true + diff --git a/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/AiToolSpecConverter.java b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/AiToolSpecConverter.java new file mode 100644 index 000000000000..adf443d9fc1b --- /dev/null +++ b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/AiToolSpecConverter.java @@ -0,0 +1,29 @@ +/* + * 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.quarkus.component.support.langchain4j; + +import dev.langchain4j.agent.tool.ToolSpecification; +import org.apache.camel.component.ai.tool.AiToolSpec; + +/** + * Converts a Camel {@link AiToolSpec} to a langchain4j {@link ToolSpecification}. + * The implementation is generated at build time via Gizmo to avoid a compile-time + * dependency on {@code camel-langchain4j-agent}. + */ +public interface AiToolSpecConverter { + ToolSpecification toToolSpecification(AiToolSpec spec); +} diff --git a/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/CamelAiToolProvider.java b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/CamelAiToolProvider.java new file mode 100644 index 000000000000..dbe66ddf67bd --- /dev/null +++ b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/CamelAiToolProvider.java @@ -0,0 +1,129 @@ +/* + * 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.quarkus.component.support.langchain4j; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.langchain4j.agent.tool.ToolExecutionRequest; +import dev.langchain4j.agent.tool.ToolSpecification; +import dev.langchain4j.service.tool.ToolExecutor; +import dev.langchain4j.service.tool.ToolProvider; +import dev.langchain4j.service.tool.ToolProviderRequest; +import dev.langchain4j.service.tool.ToolProviderResult; +import jakarta.inject.Inject; +import org.apache.camel.CamelContext; +import org.apache.camel.Exchange; +import org.apache.camel.component.ai.tool.AiToolExecutor; +import org.apache.camel.component.ai.tool.AiToolRegistry; +import org.apache.camel.component.ai.tool.AiToolResult; +import org.apache.camel.component.ai.tool.AiToolSpec; +import org.apache.camel.support.DefaultExchange; +import org.jboss.logging.Logger; + +/** + * Bridges Camel's {@link AiToolRegistry} to langchain4j's {@link ToolProvider} SPI. When registered as a CDI bean (done + * automatically by the deployment processor when both {@code camel-ai-tool} and quarkus-langchain4j are on the + * classpath), all Camel routes registered via {@code ai-tool:} endpoints become available to + * {@code @RegisterAiService} AI services without explicit configuration. + */ +public class CamelAiToolProvider implements ToolProvider { + + private static final Logger LOG = Logger.getLogger(CamelAiToolProvider.class); + static final Map TAG_MAP = new ConcurrentHashMap<>(); + // Set by CamelAiToolsInterceptor before each AI service call so provideTools() can filter by the calling service's tag + private static final ThreadLocal CURRENT_TAG = new ThreadLocal<>(); + + @Inject + CamelContext camelContext; + + @Inject + ObjectMapper objectMapper; + + @Inject + AiToolSpecConverter converter; + + static String getCurrentTag() { + return CURRENT_TAG.get(); + } + + static void setCurrentTag(String tag) { + CURRENT_TAG.set(tag); + } + + static void clearCurrentTag() { + CURRENT_TAG.remove(); + } + + @Override + public ToolProviderResult provideTools(ToolProviderRequest request) { + AiToolRegistry registry = AiToolRegistry.getOrCreate(camelContext); + String effectiveTag = CURRENT_TAG.get(); + Set tools = effectiveTag != null ? registry.getToolsByTag(effectiveTag) : registry.getAllTools(); + + ToolProviderResult.Builder resultBuilder = ToolProviderResult.builder(); + for (AiToolSpec spec : tools) { + ToolSpecification toolSpec = converter.toToolSpecification(spec); + ToolExecutor executor = createExecutor(spec); + resultBuilder.add(toolSpec, executor); + } + + return resultBuilder.build(); + } + + private ToolExecutor createExecutor(AiToolSpec spec) { + return (ToolExecutionRequest request, Object memoryId) -> { + Map arguments = parseArguments(request); + if (arguments == null) { + return "Invalid arguments: could not parse the provided JSON arguments: " + request.arguments(); + } + Exchange exchange = new DefaultExchange(camelContext); + AiToolResult result = AiToolExecutor.execute(spec, arguments, exchange); + return toToolResponse(spec.getName(), result); + }; + } + + private Map parseArguments(ToolExecutionRequest request) { + String jsonArguments = request.arguments(); + if (jsonArguments == null || jsonArguments.trim().isEmpty()) { + return Map.of(); + } + try { + return objectMapper.readValue(jsonArguments, new TypeReference<>() { + }); + } catch (Exception e) { + LOG.debugf(e, "Failed to parse tool arguments: %s", jsonArguments); + return null; + } + } + + private String toToolResponse(String toolName, AiToolResult result) { + if (result instanceof AiToolResult.Success success) { + return success.value(); + } else if (result instanceof AiToolResult.ArgumentError error) { + return "Invalid arguments: " + error.message(); + } else if (result instanceof AiToolResult.ExecutionError error) { + LOG.warnf("Tool '%s' execution failed: %s", toolName, error.message()); + return "Tool execution failed"; + } + return "Tool execution failed"; + } + +} diff --git a/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/CamelAiTools.java b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/CamelAiTools.java new file mode 100644 index 000000000000..8be0eeacbff8 --- /dev/null +++ b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/CamelAiTools.java @@ -0,0 +1,48 @@ +/* + * 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.quarkus.component.support.langchain4j; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import jakarta.enterprise.util.Nonbinding; +import jakarta.interceptor.InterceptorBinding; + +/** + * Filters the Camel AI tools exposed to a {@code @RegisterAiService} by tag. When placed on a + * {@code @RegisterAiService} interface, only {@code ai-tool:} routes whose {@code tags} parameter includes the + * specified value (plus any routes in the default pool) are provided to the AI service. + * + *
+ * @RegisterAiService
+ * @CamelAiTools("support")
+ * public interface SupportAgent {
+ *     String chat(@UserMessage String message);
+ * }
+ * 
+ */ +@InterceptorBinding +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface CamelAiTools { + @Nonbinding + String value() default ""; +} diff --git a/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/CamelAiToolsInterceptor.java b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/CamelAiToolsInterceptor.java new file mode 100644 index 000000000000..239ab269762a --- /dev/null +++ b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/CamelAiToolsInterceptor.java @@ -0,0 +1,73 @@ +/* + * 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.quarkus.component.support.langchain4j; + +import jakarta.annotation.Priority; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InvocationContext; + +/** + * CDI interceptor that sets the current Camel AI tool tag on a ThreadLocal before an AI service method executes. + * This allows {@link CamelAiToolProvider#provideTools} to filter tools by the tag associated with the calling AI + * service, + * enabling multiple {@code @RegisterAiService} interfaces with different {@code @CamelAiTools} tags in the same + * application. + */ +@Interceptor +@CamelAiTools +@Priority(Interceptor.Priority.LIBRARY_BEFORE + 100) +public class CamelAiToolsInterceptor { + + @AroundInvoke + Object aroundInvoke(InvocationContext ctx) throws Exception { + Class targetClass = ctx.getTarget().getClass(); + String tag = resolveTag(targetClass); + String previous = CamelAiToolProvider.getCurrentTag(); + if (tag != null) { + CamelAiToolProvider.setCurrentTag(tag); + } + try { + return ctx.proceed(); + } finally { + if (previous != null) { + CamelAiToolProvider.setCurrentTag(previous); + } else { + CamelAiToolProvider.clearCurrentTag(); + } + } + } + + private String resolveTag(Class targetClass) { + // Walk the full class hierarchy: ArC creates $$QuarkusImpl_Subclass extending $$QuarkusImpl, + // and the @CamelAiTools interface is declared on $$QuarkusImpl (not the subclass), so we + // must check superclasses and all their interfaces. + for (Class clazz = targetClass; clazz != null && clazz != Object.class; clazz = clazz.getSuperclass()) { + String tag = CamelAiToolProvider.TAG_MAP.get(clazz.getName()); + if (tag != null) { + return tag; + } + for (Class iface : clazz.getInterfaces()) { + tag = CamelAiToolProvider.TAG_MAP.get(iface.getName()); + if (tag != null) { + return tag; + } + } + } + return null; + } +} diff --git a/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/QuarkusLangchain4jRecorder.java b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/QuarkusLangchain4jRecorder.java index af6301a6c939..ac23912b1bdf 100644 --- a/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/QuarkusLangchain4jRecorder.java +++ b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/QuarkusLangchain4jRecorder.java @@ -17,6 +17,7 @@ package org.apache.camel.quarkus.component.support.langchain4j; import java.lang.reflect.InvocationTargetException; +import java.util.Map; import dev.langchain4j.guardrail.Guardrail; import io.quarkus.runtime.RuntimeValue; @@ -26,6 +27,10 @@ @Recorder public class QuarkusLangchain4jRecorder { + public void setCamelAiToolTagMap(Map tagMap) { + CamelAiToolProvider.TAG_MAP.putAll(tagMap); + } + public RuntimeValue> instantiateGuardrails(Class> guardrailClass) { try { return new RuntimeValue<>(guardrailClass.getConstructor().newInstance()); diff --git a/extensions/ai-tool/runtime/src/main/doc/usage.adoc b/extensions/ai-tool/runtime/src/main/doc/usage.adoc index 677f114f7cb6..36359bf4106f 100644 --- a/extensions/ai-tool/runtime/src/main/doc/usage.adoc +++ b/extensions/ai-tool/runtime/src/main/doc/usage.adoc @@ -41,3 +41,57 @@ from("ai-tool:greet?description=Greet a user" + "¶meter.name=string¶meter.name.required=true") .setBody(simple("Hello, ${header.name}!")); ---- + +=== LangChain4j integration + +When both `camel-quarkus-ai-tool` and a Quarkus LangChain4j extension (`quarkus-langchain4j-ollama`, `quarkus-langchain4j-openai`, etc.) are on the classpath, a `ToolProvider` CDI bean is registered automatically at build time. +All routes registered via `ai-tool:` endpoints become available to `@RegisterAiService` AI services without explicit wiring. + +You must also add `camel-langchain4j-agent` to your project dependencies. This artifact provides the `AiToolSpec` to `ToolSpecification` conversion and is not brought in transitively. + +[source,xml] +---- + + org.apache.camel + camel-langchain4j-agent + +---- + +With both dependencies in place, a `@RegisterAiService` interface can call Camel tools directly: + +[source,java] +---- +@RegisterAiService +public interface WeatherAiService { + String chat(@UserMessage String question); +} +---- + +Any `ai-tool:` route is now callable by the LLM behind `WeatherAiService`. No `toolProvider` attribute or manual bean reference is needed. + +==== Tag filtering with `@CamelAiTools` + +By default, all registered `ai-tool:` routes are visible to every AI service. When you need different AI services to see different subsets of tools, annotate the service interface with `@CamelAiTools` and specify a tag value that matches the `tags` parameter on your `ai-tool:` routes. + +[source,java] +---- +@RegisterAiService +@CamelAiTools("weather") +public interface WeatherAgent { + String chat(@UserMessage String question); +} + +@RegisterAiService +@CamelAiTools("support") +public interface SupportAgent { + String chat(@UserMessage String question); +} +---- + +With this setup: + +* `WeatherAgent` sees only tools tagged with `weather` (plus any untagged tools from the default pool). +* `SupportAgent` sees only tools tagged with `support` (plus any untagged tools from the default pool). +* A `@RegisterAiService` without `@CamelAiTools` sees all registered tools regardless of tags. + +NOTE: The `@CamelAiTools` value must match a tag declared in the `ai-tool:` endpoint URI (e.g. `from("ai-tool:getWeather?tags=weather&...")`). If the annotation value is blank or does not match any route tag, the AI service will only see untagged tools. diff --git a/integration-tests/ai-tool-langchain4j/README.adoc b/integration-tests/ai-tool-langchain4j/README.adoc new file mode 100644 index 000000000000..40a56d5e2416 --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/README.adoc @@ -0,0 +1,15 @@ +== Camel Quarkus AI Tool LangChain4j Integration Tests + +By default, the tests use a mock `ChatModel` that simulates tool calling without a real LLM. + +=== Running with a real Ollama LLM + +To run additional tests against a real Ollama model (qwen3:1.7b) via dev services, activate the `ollama` Maven profile. +This requires Docker to be running. + +[source,shell] +---- +./mvnw verify -f integration-tests/ai-tool-langchain4j -Pollama +---- + +The first run may take longer as the Ollama container image and model are downloaded. diff --git a/integration-tests/ai-tool-langchain4j/pom.xml b/integration-tests/ai-tool-langchain4j/pom.xml new file mode 100644 index 000000000000..433e1df1898c --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/pom.xml @@ -0,0 +1,222 @@ + + + + 4.0.0 + + org.apache.camel.quarkus + camel-quarkus-build-parent-it + 3.39.0-SNAPSHOT + ../../poms/build-parent-it/pom.xml + + + camel-quarkus-integration-test-ai-tool-langchain4j + Camel Quarkus :: Integration Tests :: AI Tool LangChain4j + Integration tests for Camel AI Tool bridge with Quarkus LangChain4j + + + + + io.quarkiverse.langchain4j + quarkus-langchain4j-bom + ${quarkiverse-langchain4j.version} + pom + import + + + + + + + org.apache.camel.quarkus + camel-quarkus-ai-tool + + + org.apache.camel.quarkus + camel-quarkus-bean + + + org.apache.camel.quarkus + camel-quarkus-direct + + + io.quarkus + quarkus-rest + + + + + org.apache.camel.quarkus + camel-quarkus-support-langchain4j + + + + org.apache.camel + camel-langchain4j-agent + + + + io.quarkiverse.langchain4j + quarkus-langchain4j-ollama + + + + + io.quarkus + quarkus-junit + test + + + io.rest-assured + rest-assured + test + + + + + + native + + + native + + + + true + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + + + + + + ollama + + + + org.apache.maven.plugins + maven-surefire-plugin + + + true + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + true + + + + + + + + virtualDependencies + + + !noVirtualDependencies + + + + + + org.apache.camel.quarkus + camel-quarkus-ai-tool-deployment + ${project.version} + pom + test + + + * + * + + + + + org.apache.camel.quarkus + camel-quarkus-bean-deployment + ${project.version} + pom + test + + + * + * + + + + + org.apache.camel.quarkus + camel-quarkus-direct-deployment + ${project.version} + pom + test + + + * + * + + + + + org.apache.camel.quarkus + camel-quarkus-support-langchain4j-deployment + ${project.version} + pom + test + + + * + * + + + + + + + skip-testcontainers-tests + + + skip-testcontainers-tests + + + + true + + + + diff --git a/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jOllamaResource.java b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jOllamaResource.java new file mode 100644 index 000000000000..60fdd44967e6 --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jOllamaResource.java @@ -0,0 +1,54 @@ +/* + * 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.quarkus.component.ai.tool.langchain4j.it; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.apache.camel.quarkus.component.ai.tool.langchain4j.it.service.WeatherAiServiceOllama; +import org.apache.camel.quarkus.component.ai.tool.langchain4j.it.service.WeatherAiServiceOllamaWrongTag; + +@Path("/ai-tool-langchain4j-ollama") +@ApplicationScoped +public class AiToolLangchain4jOllamaResource { + + @Inject + WeatherAiServiceOllama weatherAiService; + + @Inject + WeatherAiServiceOllamaWrongTag weatherAiServiceWrongTag; + + @Path("/chat") + @POST + @Consumes(MediaType.TEXT_PLAIN) + @Produces(MediaType.TEXT_PLAIN) + public String chat(String message) { + return weatherAiService.chat(message); + } + + @Path("/chat-wrong-tag") + @POST + @Consumes(MediaType.TEXT_PLAIN) + @Produces(MediaType.TEXT_PLAIN) + public String chatWrongTag(String message) { + return weatherAiServiceWrongTag.chat(message); + } +} diff --git a/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jResource.java b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jResource.java new file mode 100644 index 000000000000..82770665ac2a --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jResource.java @@ -0,0 +1,90 @@ +/* + * 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.quarkus.component.ai.tool.langchain4j.it; + +import java.util.Set; +import java.util.stream.Collectors; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.apache.camel.CamelContext; +import org.apache.camel.component.ai.tool.AiToolRegistry; +import org.apache.camel.component.ai.tool.AiToolSpec; +import org.apache.camel.quarkus.component.ai.tool.langchain4j.it.service.AdminAiService; +import org.apache.camel.quarkus.component.ai.tool.langchain4j.it.service.WeatherAiService; +import org.apache.camel.quarkus.component.ai.tool.langchain4j.it.service.WeatherAiServiceWrongTag; + +@Path("/ai-tool-langchain4j") +@ApplicationScoped +public class AiToolLangchain4jResource { + + @Inject + CamelContext camelContext; + + @Inject + WeatherAiService weatherAiService; + + @Inject + AdminAiService adminAiService; + + @Inject + WeatherAiServiceWrongTag weatherAiServiceWrongTag; + + @Path("/weather/chat") + @POST + @Consumes(MediaType.TEXT_PLAIN) + @Produces(MediaType.TEXT_PLAIN) + public String weatherChat(String message) { + return weatherAiService.chat(message); + } + + @Path("/admin/chat") + @POST + @Consumes(MediaType.TEXT_PLAIN) + @Produces(MediaType.TEXT_PLAIN) + public String adminChat(String message) { + return adminAiService.chat(message); + } + + @Path("/weather-wrong-tag/chat") + @POST + @Consumes(MediaType.TEXT_PLAIN) + @Produces(MediaType.TEXT_PLAIN) + public String weatherChatWrongTag(String message) { + return weatherAiServiceWrongTag.chat(message); + } + + @Path("/tools/{tag}") + @GET + @Produces(MediaType.TEXT_PLAIN) + public String listToolsByTag(@PathParam("tag") String tag) { + AiToolRegistry registry = AiToolRegistry.getOrCreate(camelContext); + Set tools = registry.getToolsByTag(tag); + return tools.stream() + .map(AiToolSpec::getName) + .sorted() + .collect(Collectors.joining(",")); + } + +} diff --git a/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jRoutes.java b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jRoutes.java new file mode 100644 index 000000000000..8f8b45acf66b --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jRoutes.java @@ -0,0 +1,40 @@ +/* + * 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.quarkus.component.ai.tool.langchain4j.it; + +import org.apache.camel.builder.RouteBuilder; + +public class AiToolLangchain4jRoutes extends RouteBuilder { + @Override + public void configure() { + from("ai-tool:getWeather?" + + "tags=weatherTag" + + "&description=Get the current weather for a city" + + "¶meter.city=string" + + "¶meter.city.required=true" + + "¶meter.city.description=The city name") + .setBody(simple("Sunny in ${header.city}, 1111 celsius")); + + from("ai-tool:getNews?" + + "tags=adminTag" + + "&description=Get the latest news about a topic" + + "¶meter.topic=string" + + "¶meter.topic.required=true" + + "¶meter.topic.description=The news topic") + .setBody(simple("Latest news about ${header.topic}")); + } +} diff --git a/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/model/AbstractToolCallingChatModel.java b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/model/AbstractToolCallingChatModel.java new file mode 100644 index 000000000000..a4be0986b90c --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/model/AbstractToolCallingChatModel.java @@ -0,0 +1,93 @@ +/* + * 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.quarkus.component.ai.tool.langchain4j.it.model; + +import java.util.List; +import java.util.function.Supplier; + +import dev.langchain4j.agent.tool.ToolExecutionRequest; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.ToolExecutionResultMessage; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; + +/** + * Simulates the two-turn tool-calling protocol used by langchain4j: + * + *
    + *
  1. Turn 1 — langchain4j sends the user message and available tool specifications to the model. + * The model responds with a {@link ToolExecutionRequest} ("I want to call tool X with args Y"). + * No tool is executed yet.
  2. + *
  3. Tool execution — langchain4j sees the request, invokes the matching {@code ToolExecutor} + * (in our case the Camel {@code ai-tool:} route), and captures the result.
  4. + *
  5. Turn 2 — langchain4j calls the model again, appending a {@link ToolExecutionResultMessage} + * with the tool's output. The model returns a final text answer.
  6. + *
+ * + * Subclasses only define which tool to call and how to format the final response. + */ +public abstract class AbstractToolCallingChatModel implements Supplier { + + // Correlation ID for matching ToolExecutionResultMessage back to the request — required by the + // builder but the actual value is irrelevant in tests with a single tool call per turn. + protected abstract String toolCallId(); + + protected abstract String toolName(); + + protected abstract String toolArguments(); + + protected abstract String formatResponse(String toolResult); + + protected abstract String getNameOfService(); + + @Override + public ChatModel get() { + return new ChatModel() { + @Override + public ChatResponse doChat(ChatRequest chatRequest) { + List messages = chatRequest.messages(); + + boolean hasToolResult = messages.stream() + .anyMatch(m -> m instanceof ToolExecutionResultMessage); + + if (!hasToolResult) { + ToolExecutionRequest toolRequest = ToolExecutionRequest.builder() + .id(toolCallId()) + .name(toolName()) + .arguments(toolArguments()) + .build(); + return ChatResponse.builder() + .aiMessage(AiMessage.from(toolRequest)) + .build(); + } + + // #2 tool's response already present + String toolResult = messages.stream() + .filter(m -> m instanceof ToolExecutionResultMessage) + .map(m -> ((ToolExecutionResultMessage) m).text()) + .findFirst() + .orElse("unknown"); + + return ChatResponse.builder() + .aiMessage(new AiMessage(formatResponse(toolResult))) + .build(); + } + }; + } +} diff --git a/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/model/AdminToolCallingChatModel.java b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/model/AdminToolCallingChatModel.java new file mode 100644 index 000000000000..2bff6c1e1260 --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/model/AdminToolCallingChatModel.java @@ -0,0 +1,45 @@ +/* + * 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.quarkus.component.ai.tool.langchain4j.it.model; + +public class AdminToolCallingChatModel extends AbstractToolCallingChatModel { + + @Override + protected String getNameOfService() { + return "adminTag"; + } + + @Override + protected String toolCallId() { + return "call_admin_1"; + } + + @Override + protected String toolName() { + return "getNews"; + } + + @Override + protected String toolArguments() { + return "{\"topic\":\"camel\"}"; + } + + @Override + protected String formatResponse(String toolResult) { + return "News result: " + toolResult; + } +} diff --git a/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/model/WeatherToolCallingChatModel.java b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/model/WeatherToolCallingChatModel.java new file mode 100644 index 000000000000..4a52902d80cd --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/model/WeatherToolCallingChatModel.java @@ -0,0 +1,45 @@ +/* + * 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.quarkus.component.ai.tool.langchain4j.it.model; + +public class WeatherToolCallingChatModel extends AbstractToolCallingChatModel { + + @Override + protected String getNameOfService() { + return "weatherTag"; + } + + @Override + protected String toolCallId() { + return "call_1"; + } + + @Override + protected String toolName() { + return "getWeather"; + } + + @Override + protected String toolArguments() { + return "{\"city\":\"Prague\"}"; + } + + @Override + protected String formatResponse(String toolResult) { + return toolResult; + } +} diff --git a/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/model/WrongTagToolCallingChatModel.java b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/model/WrongTagToolCallingChatModel.java new file mode 100644 index 000000000000..a4c1803a46e9 --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/model/WrongTagToolCallingChatModel.java @@ -0,0 +1,78 @@ +/* + * 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.quarkus.component.ai.tool.langchain4j.it.model; + +import java.util.List; +import java.util.function.Supplier; + +import dev.langchain4j.agent.tool.ToolExecutionRequest; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.ToolExecutionResultMessage; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; + +/** + * Mock model for testing tag isolation. Calls {@code getWeather} only if it appears in the + * available tool specifications — when the interceptor works correctly and this service is + * tagged with {@code adminTag}, the weather tool should NOT be visible. + */ +public class WrongTagToolCallingChatModel implements Supplier { + + @Override + public ChatModel get() { + return new ChatModel() { + @Override + public ChatResponse doChat(ChatRequest chatRequest) { + List messages = chatRequest.messages(); + boolean hasToolResult = messages.stream() + .anyMatch(m -> m instanceof ToolExecutionResultMessage); + + if (!hasToolResult) { + boolean weatherToolAvailable = chatRequest.toolSpecifications() != null + && chatRequest.toolSpecifications().stream() + .anyMatch(ts -> "getWeather".equals(ts.name())); + + if (weatherToolAvailable) { + ToolExecutionRequest request = ToolExecutionRequest.builder() + .id("call_wrong_tag") + .name("getWeather") + .arguments("{\"city\":\"Prague\"}") + .build(); + return ChatResponse.builder() + .aiMessage(AiMessage.from(request)) + .build(); + } + + return ChatResponse.builder() + .aiMessage(new AiMessage("No weather tool available")) + .build(); + } + + String toolResult = messages.stream() + .filter(m -> m instanceof ToolExecutionResultMessage) + .map(m -> ((ToolExecutionResultMessage) m).text()) + .findFirst() + .orElse("unknown"); + return ChatResponse.builder() + .aiMessage(new AiMessage(toolResult)) + .build(); + } + }; + } +} diff --git a/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/service/AdminAiService.java b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/service/AdminAiService.java new file mode 100644 index 000000000000..b54e0d4d1286 --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/service/AdminAiService.java @@ -0,0 +1,31 @@ +/* + * 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.quarkus.component.ai.tool.langchain4j.it.service; + +import dev.langchain4j.service.UserMessage; +import io.quarkiverse.langchain4j.RegisterAiService; +import jakarta.enterprise.context.ApplicationScoped; +import org.apache.camel.quarkus.component.ai.tool.langchain4j.it.model.AdminToolCallingChatModel; +import org.apache.camel.quarkus.component.support.langchain4j.CamelAiTools; + +@ApplicationScoped +@RegisterAiService(chatLanguageModelSupplier = AdminToolCallingChatModel.class) +@CamelAiTools("adminTag") +public interface AdminAiService { + + String chat(@UserMessage String message); +} diff --git a/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/service/WeatherAiService.java b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/service/WeatherAiService.java new file mode 100644 index 000000000000..9a1e8501a1ee --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/service/WeatherAiService.java @@ -0,0 +1,31 @@ +/* + * 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.quarkus.component.ai.tool.langchain4j.it.service; + +import dev.langchain4j.service.UserMessage; +import io.quarkiverse.langchain4j.RegisterAiService; +import jakarta.enterprise.context.ApplicationScoped; +import org.apache.camel.quarkus.component.ai.tool.langchain4j.it.model.WeatherToolCallingChatModel; +import org.apache.camel.quarkus.component.support.langchain4j.CamelAiTools; + +@ApplicationScoped +@RegisterAiService(chatLanguageModelSupplier = WeatherToolCallingChatModel.class) +@CamelAiTools("weatherTag") +public interface WeatherAiService { + + String chat(@UserMessage String message); +} diff --git a/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/service/WeatherAiServiceOllama.java b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/service/WeatherAiServiceOllama.java new file mode 100644 index 000000000000..76829813cb69 --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/service/WeatherAiServiceOllama.java @@ -0,0 +1,30 @@ +/* + * 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.quarkus.component.ai.tool.langchain4j.it.service; + +import dev.langchain4j.service.UserMessage; +import io.quarkiverse.langchain4j.RegisterAiService; +import jakarta.enterprise.context.ApplicationScoped; +import org.apache.camel.quarkus.component.support.langchain4j.CamelAiTools; + +@ApplicationScoped +@RegisterAiService +@CamelAiTools("weatherTag") +public interface WeatherAiServiceOllama { + + String chat(@UserMessage String message); +} diff --git a/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/service/WeatherAiServiceOllamaWrongTag.java b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/service/WeatherAiServiceOllamaWrongTag.java new file mode 100644 index 000000000000..1545589a3375 --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/service/WeatherAiServiceOllamaWrongTag.java @@ -0,0 +1,30 @@ +/* + * 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.quarkus.component.ai.tool.langchain4j.it.service; + +import dev.langchain4j.service.UserMessage; +import io.quarkiverse.langchain4j.RegisterAiService; +import jakarta.enterprise.context.ApplicationScoped; +import org.apache.camel.quarkus.component.support.langchain4j.CamelAiTools; + +@ApplicationScoped +@RegisterAiService +@CamelAiTools("adminTag") +public interface WeatherAiServiceOllamaWrongTag { + + String chat(@UserMessage String message); +} diff --git a/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/service/WeatherAiServiceWrongTag.java b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/service/WeatherAiServiceWrongTag.java new file mode 100644 index 000000000000..7e4a78d7b6a8 --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/src/main/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/service/WeatherAiServiceWrongTag.java @@ -0,0 +1,31 @@ +/* + * 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.quarkus.component.ai.tool.langchain4j.it.service; + +import dev.langchain4j.service.UserMessage; +import io.quarkiverse.langchain4j.RegisterAiService; +import jakarta.enterprise.context.ApplicationScoped; +import org.apache.camel.quarkus.component.ai.tool.langchain4j.it.model.WrongTagToolCallingChatModel; +import org.apache.camel.quarkus.component.support.langchain4j.CamelAiTools; + +@ApplicationScoped +@RegisterAiService(chatLanguageModelSupplier = WrongTagToolCallingChatModel.class) +@CamelAiTools("adminTag") +public interface WeatherAiServiceWrongTag { + + String chat(@UserMessage String message); +} diff --git a/integration-tests/ai-tool-langchain4j/src/main/resources/application.properties b/integration-tests/ai-tool-langchain4j/src/main/resources/application.properties new file mode 100644 index 000000000000..a58bbb874a93 --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/src/main/resources/application.properties @@ -0,0 +1,18 @@ +## --------------------------------------------------------------------------- +## 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. +## --------------------------------------------------------------------------- +quarkus.devservices.enabled=false +quarkus.langchain4j.ollama.chat-model.model-id=qwen3:1.7b diff --git a/integration-tests/ai-tool-langchain4j/src/test/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jIT.java b/integration-tests/ai-tool-langchain4j/src/test/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jIT.java new file mode 100644 index 000000000000..40dd473753c9 --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/src/test/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jIT.java @@ -0,0 +1,23 @@ +/* + * 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.quarkus.component.ai.tool.langchain4j.it; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +class AiToolLangchain4jIT extends AiToolLangchain4jTest { +} diff --git a/integration-tests/ai-tool-langchain4j/src/test/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jOllamaIT.java b/integration-tests/ai-tool-langchain4j/src/test/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jOllamaIT.java new file mode 100644 index 000000000000..b263b5abc2d3 --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/src/test/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jOllamaIT.java @@ -0,0 +1,25 @@ +/* + * 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.quarkus.component.ai.tool.langchain4j.it; + +import io.quarkus.test.junit.QuarkusIntegrationTest; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +@QuarkusIntegrationTest +@EnabledIfSystemProperty(named = "ollama.test", matches = "true") +class AiToolLangchain4jOllamaIT extends AiToolLangchain4jOllamaTest { +} diff --git a/integration-tests/ai-tool-langchain4j/src/test/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jOllamaTest.java b/integration-tests/ai-tool-langchain4j/src/test/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jOllamaTest.java new file mode 100644 index 000000000000..d4b5fef08d2a --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/src/test/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jOllamaTest.java @@ -0,0 +1,68 @@ +/* + * 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.quarkus.component.ai.tool.langchain4j.it; + +import java.util.Map; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import io.restassured.RestAssured; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; + +@QuarkusTest +@TestProfile(AiToolLangchain4jOllamaTest.OllamaProfile.class) +@EnabledIfSystemProperty(named = "ollama.test", matches = "true") +class AiToolLangchain4jOllamaTest { + + @Test + void weatherFromToolTest() { + RestAssured.given() + .contentType("text/plain") + .body("What is the weather in Prague?") + .post("/ai-tool-langchain4j-ollama/chat") + .then() + .statusCode(200) + .body(containsString("1111")); + } + + // Verifies that the @CamelAiTools interceptor filters tools by tag: a service tagged "adminTag" + // should not see the getWeather tool (tagged "weatherTag"), so the real LLM won't call it + // and "1111" won't appear in the response. Fails if the interceptor is broken and all tools are visible. + @Test + void weatherWithWrongTag() { + RestAssured.given() + .contentType("text/plain") + .body("What is the weather in Prague?") + .post("/ai-tool-langchain4j-ollama/chat-wrong-tag") + .then() + .statusCode(200) + .body(not(containsString("1111"))); + } + + public static class OllamaProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.devservices.enabled", "true"); + } + } +} diff --git a/integration-tests/ai-tool-langchain4j/src/test/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jTest.java b/integration-tests/ai-tool-langchain4j/src/test/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jTest.java new file mode 100644 index 000000000000..2b7cf401a679 --- /dev/null +++ b/integration-tests/ai-tool-langchain4j/src/test/java/org/apache/camel/quarkus/component/ai/tool/langchain4j/it/AiToolLangchain4jTest.java @@ -0,0 +1,97 @@ +/* + * 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.quarkus.component.ai.tool.langchain4j.it; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; + +@QuarkusTest +class AiToolLangchain4jTest { + + @Test + void weatherServiceTest() { + RestAssured.given() + .contentType("text/plain") + .body("What is the weather in Prague?") + .post("/ai-tool-langchain4j/weather/chat") + .then() + .statusCode(200) + .body(containsString("1111")); + } + + @Test + void weatherServiceTestWithWrongTag() { + RestAssured.given() + .contentType("text/plain") + .body("What is the weather in Prague?") + .post("/ai-tool-langchain4j/admin/chat") + .then() + .statusCode(200) + .body(containsString("Latest news about camel")); + } + + @Test + void aiToolRegistryToolsByWeatherTagTest() { + RestAssured.given() + .get("/ai-tool-langchain4j/tools/weatherTag") + .then() + .statusCode(200) + .body(containsString("getWeather")) + .body(not(containsString("getNews"))); + } + + @Test + void aiToolRegistryToolsByAdminTagTest() { + RestAssured.given() + .get("/ai-tool-langchain4j/tools/adminTag") + .then() + .statusCode(200) + .body(containsString("getNews")) + .body(not(containsString("getWeather"))); + } + + @Test + void adminServiceTest() { + RestAssured.given() + .contentType("text/plain") + .body("What are the latest news about camel?") + .post("/ai-tool-langchain4j/admin/chat") + .then() + .statusCode(200) + .body(containsString("camel")) + .body(containsString("news")); + } + + // Verifies that the @CamelAiTools interceptor filters tools by tag: a service tagged "adminTag" + // should not see the getWeather tool (tagged "weatherTag"), so the mock model won't call it + // and "1111" won't appear in the response. Fails if the interceptor is broken and all tools are visible. + @Test + void weatherWithWrongTagTest() { + RestAssured.given() + .contentType("text/plain") + .body("What is the weather in Prague?") + .post("/ai-tool-langchain4j/weather-wrong-tag/chat") + .then() + .statusCode(200) + .body(not(containsString("1111"))); + } + +} diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 8ffee5a4dd62..ce58500c8370 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -52,6 +52,7 @@ activemq activemq6 ai-tool + ai-tool-langchain4j amqp arangodb as2 diff --git a/tooling/scripts/test-categories.yaml b/tooling/scripts/test-categories.yaml index a4df39026536..97b679b994ca 100644 --- a/tooling/scripts/test-categories.yaml +++ b/tooling/scripts/test-categories.yaml @@ -232,6 +232,7 @@ group-11: - xslt-saxon group-12: - ai-tool + - ai-tool-langchain4j - aws2-grouped - csimple - github2