diff --git a/bom/camel-bom/pom.xml b/bom/camel-bom/pom.xml index 9643d0a027ffc..bc88a80d9c3ff 100644 --- a/bom/camel-bom/pom.xml +++ b/bom/camel-bom/pom.xml @@ -61,6 +61,11 @@ camel-activemq6 4.22.0-SNAPSHOT + + org.apache.camel + camel-ai-tool + 4.22.0-SNAPSHOT + org.apache.camel camel-amqp diff --git a/catalog/camel-allcomponents/pom.xml b/catalog/camel-allcomponents/pom.xml index ed4edb12550ee..1a90dfd62cd20 100644 --- a/catalog/camel-allcomponents/pom.xml +++ b/catalog/camel-allcomponents/pom.xml @@ -67,6 +67,11 @@ camel-activemq6 ${project.version} + + org.apache.camel + camel-ai-tool + ${project.version} + org.apache.camel camel-amqp diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components.properties b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components.properties index f81bc9f23152c..91be0f1a521e0 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components.properties +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components.properties @@ -1,6 +1,7 @@ a2a activemq activemq6 +ai-tool amqp arangodb as2 diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ai-tool.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ai-tool.json new file mode 100644 index 0000000000000..f0fd90ba449e6 --- /dev/null +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ai-tool.json @@ -0,0 +1,43 @@ +{ + "component": { + "kind": "component", + "name": "ai-tool", + "title": "AI Tool", + "description": "Framework-agnostic consumer endpoint that registers a Camel route as an LLM tool in the shared AiToolRegistry.", + "deprecated": false, + "firstVersion": "4.22.0", + "label": "ai", + "javaType": "org.apache.camel.component.ai.tool.AiToolComponent", + "supportLevel": "Preview", + "groupId": "org.apache.camel", + "artifactId": "camel-ai-tool", + "version": "4.22.0-SNAPSHOT", + "scheme": "ai-tool", + "extendsScheme": "", + "syntax": "ai-tool:toolName", + "async": false, + "api": false, + "consumerOnly": true, + "producerOnly": false, + "lenientProperties": false, + "browsable": false, + "remote": false + }, + "componentProperties": { + "bridgeErrorHandler": { "index": 0, "kind": "property", "displayName": "Bridge Error Handler", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Allows for bridging the consumer to the Camel routing Error Handler, which mean any exceptions (if possible) occurred while the Camel consumer is trying to pickup incoming messages, or the likes, will now be processed as a message and handled by the routing Error Handler. Important: This is only possible if the 3rd party component allows Camel to be alerted if an exception was thrown. Some components handle this internally only, and therefore bridgeErrorHandler is not possible. In other situations we may improve the Camel component to hook into the 3rd party component and make this possible for future releases. By default the consumer will use the org.apache.camel.spi.ExceptionHandler to deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, + "configuration": { "index": 1, "kind": "property", "displayName": "Configuration", "group": "consumer", "label": "", "required": false, "type": "object", "javaType": "org.apache.camel.component.ai.tool.AiToolConfiguration", "deprecated": false, "autowired": false, "secret": false, "description": "The component configuration" }, + "description": { "index": 2, "kind": "property", "displayName": "Description", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Human-readable description of what this tool does. Passed verbatim to the LLM; be precise and action-oriented. When omitted, defaults to the tool name." }, + "parameters": { "index": 3, "kind": "property", "displayName": "Parameters", "group": "consumer", "label": "consumer", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "parameter.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Tool input parameters. Format: parameter.NAME=TYPE, parameter.NAME.description=TEXT, parameter.NAME.required=true or false, parameter.NAME.enum=val1,val2. Supported types: string, integer, number, boolean. This is a multi-value option with prefix: parameter." }, + "tags": { "index": 4, "kind": "property", "displayName": "Tags", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Comma-separated list of tags used to group tools. Producers filter the registry by these tags to select which tools to expose to the LLM. When omitted, the tool goes into a default pool available to all producers." }, + "autowiredEnabled": { "index": 5, "kind": "property", "displayName": "Autowired Enabled", "group": "advanced", "label": "advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": true, "description": "Whether autowiring is enabled. This is used for automatic autowiring options (the option must be marked as autowired) by looking up in the registry to find if there is a single instance of matching type, which then gets configured on the component. This can be used for automatic configuring JDBC data sources, JMS connection factories, AWS Clients, etc." } + }, + "properties": { + "toolName": { "index": 0, "kind": "path", "displayName": "Tool Name", "group": "consumer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The tool name. This is the name the LLM sees and uses to invoke the tool." }, + "description": { "index": 1, "kind": "parameter", "displayName": "Description", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Human-readable description of what this tool does. Passed verbatim to the LLM; be precise and action-oriented. When omitted, defaults to the tool name." }, + "parameters": { "index": 2, "kind": "parameter", "displayName": "Parameters", "group": "consumer", "label": "consumer", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "parameter.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Tool input parameters. Format: parameter.NAME=TYPE, parameter.NAME.description=TEXT, parameter.NAME.required=true or false, parameter.NAME.enum=val1,val2. Supported types: string, integer, number, boolean. This is a multi-value option with prefix: parameter." }, + "tags": { "index": 3, "kind": "parameter", "displayName": "Tags", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Comma-separated list of tags used to group tools. Producers filter the registry by these tags to select which tools to expose to the LLM. When omitted, the tool goes into a default pool available to all producers." }, + "bridgeErrorHandler": { "index": 4, "kind": "parameter", "displayName": "Bridge Error Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Allows for bridging the consumer to the Camel routing Error Handler, which mean any exceptions (if possible) occurred while the Camel consumer is trying to pickup incoming messages, or the likes, will now be processed as a message and handled by the routing Error Handler. Important: This is only possible if the 3rd party component allows Camel to be alerted if an exception was thrown. Some components handle this internally only, and therefore bridgeErrorHandler is not possible. In other situations we may improve the Camel component to hook into the 3rd party component and make this possible for future releases. By default the consumer will use the org.apache.camel.spi.ExceptionHandler to deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, + "exceptionHandler": { "index": 5, "kind": "parameter", "displayName": "Exception Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "object", "javaType": "org.apache.camel.spi.ExceptionHandler", "optionalPrefix": "consumer.", "deprecated": false, "autowired": false, "secret": false, "description": "To let the consumer use a custom ExceptionHandler. Notice if the option bridgeErrorHandler is enabled then this option is not in use. By default the consumer will deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, + "exchangePattern": { "index": 6, "kind": "parameter", "displayName": "Exchange Pattern", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "enum", "javaType": "org.apache.camel.ExchangePattern", "enum": [ "InOnly", "InOut" ], "deprecated": false, "autowired": false, "secret": false, "description": "Sets the exchange pattern when the consumer creates an exchange." } + } +} diff --git a/components/camel-ai/camel-ai-tool/pom.xml b/components/camel-ai/camel-ai-tool/pom.xml new file mode 100644 index 0000000000000..384697603ffbd --- /dev/null +++ b/components/camel-ai/camel-ai-tool/pom.xml @@ -0,0 +1,66 @@ + + + + + 4.0.0 + + org.apache.camel + camel-ai-parent + 4.22.0-SNAPSHOT + + + camel-ai-tool + jar + Camel :: AI :: Tool + Framework-agnostic Camel AI tool abstraction for route-based LLM tools + + + 4.22.0 + + AI Tool + Preview + + + + + org.apache.camel + camel-support + + + + org.apache.camel + camel-test-junit6 + test + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + diff --git a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolComponentConfigurer.java b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolComponentConfigurer.java new file mode 100644 index 0000000000000..5eb0aeb43c848 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolComponentConfigurer.java @@ -0,0 +1,84 @@ +/* Generated by camel build tools - do NOT edit this file! */ +package org.apache.camel.component.ai.tool; + +import javax.annotation.processing.Generated; +import java.util.Map; + +import org.apache.camel.CamelContext; +import org.apache.camel.spi.ExtendedPropertyConfigurerGetter; +import org.apache.camel.spi.PropertyConfigurerGetter; +import org.apache.camel.spi.ConfigurerStrategy; +import org.apache.camel.spi.GeneratedPropertyConfigurer; +import org.apache.camel.util.CaseInsensitiveMap; +import org.apache.camel.support.component.PropertyConfigurerSupport; + +/** + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.EndpointSchemaGeneratorMojo") +@SuppressWarnings("unchecked") +public class AiToolComponentConfigurer extends PropertyConfigurerSupport implements GeneratedPropertyConfigurer, PropertyConfigurerGetter { + + private org.apache.camel.component.ai.tool.AiToolConfiguration getOrCreateConfiguration(AiToolComponent target) { + if (target.getConfiguration() == null) { + target.setConfiguration(new org.apache.camel.component.ai.tool.AiToolConfiguration()); + } + return target.getConfiguration(); + } + + @Override + public boolean configure(CamelContext camelContext, Object obj, String name, Object value, boolean ignoreCase) { + AiToolComponent target = (AiToolComponent) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "autowiredenabled": + case "autowiredEnabled": target.setAutowiredEnabled(property(camelContext, boolean.class, value)); return true; + case "bridgeerrorhandler": + case "bridgeErrorHandler": target.setBridgeErrorHandler(property(camelContext, boolean.class, value)); return true; + case "configuration": target.setConfiguration(property(camelContext, org.apache.camel.component.ai.tool.AiToolConfiguration.class, value)); return true; + case "description": getOrCreateConfiguration(target).setDescription(property(camelContext, java.lang.String.class, value)); return true; + case "parameters": getOrCreateConfiguration(target).setParameters(property(camelContext, java.util.Map.class, value)); return true; + case "tags": getOrCreateConfiguration(target).setTags(property(camelContext, java.lang.String.class, value)); return true; + default: return false; + } + } + + @Override + public Class getOptionType(String name, boolean ignoreCase) { + switch (ignoreCase ? name.toLowerCase() : name) { + case "autowiredenabled": + case "autowiredEnabled": return boolean.class; + case "bridgeerrorhandler": + case "bridgeErrorHandler": return boolean.class; + case "configuration": return org.apache.camel.component.ai.tool.AiToolConfiguration.class; + case "description": return java.lang.String.class; + case "parameters": return java.util.Map.class; + case "tags": return java.lang.String.class; + default: return null; + } + } + + @Override + public Object getOptionValue(Object obj, String name, boolean ignoreCase) { + AiToolComponent target = (AiToolComponent) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "autowiredenabled": + case "autowiredEnabled": return target.isAutowiredEnabled(); + case "bridgeerrorhandler": + case "bridgeErrorHandler": return target.isBridgeErrorHandler(); + case "configuration": return target.getConfiguration(); + case "description": return getOrCreateConfiguration(target).getDescription(); + case "parameters": return getOrCreateConfiguration(target).getParameters(); + case "tags": return getOrCreateConfiguration(target).getTags(); + default: return null; + } + } + + @Override + public Object getCollectionValueType(Object target, String name, boolean ignoreCase) { + switch (ignoreCase ? name.toLowerCase() : name) { + case "parameters": return java.lang.String.class; + default: return null; + } + } +} + diff --git a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolConfigurationConfigurer.java b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolConfigurationConfigurer.java new file mode 100644 index 0000000000000..5639cfc7474cb --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolConfigurationConfigurer.java @@ -0,0 +1,62 @@ +/* Generated by camel build tools - do NOT edit this file! */ +package org.apache.camel.component.ai.tool; + +import javax.annotation.processing.Generated; +import java.util.Map; + +import org.apache.camel.CamelContext; +import org.apache.camel.spi.ExtendedPropertyConfigurerGetter; +import org.apache.camel.spi.PropertyConfigurerGetter; +import org.apache.camel.spi.ConfigurerStrategy; +import org.apache.camel.spi.GeneratedPropertyConfigurer; +import org.apache.camel.util.CaseInsensitiveMap; +import org.apache.camel.component.ai.tool.AiToolConfiguration; + +/** + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.GenerateConfigurerMojo") +@SuppressWarnings("unchecked") +public class AiToolConfigurationConfigurer extends org.apache.camel.support.component.PropertyConfigurerSupport implements GeneratedPropertyConfigurer, PropertyConfigurerGetter { + + @Override + public boolean configure(CamelContext camelContext, Object obj, String name, Object value, boolean ignoreCase) { + org.apache.camel.component.ai.tool.AiToolConfiguration target = (org.apache.camel.component.ai.tool.AiToolConfiguration) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "description": target.setDescription(property(camelContext, java.lang.String.class, value)); return true; + case "parameters": target.setParameters(property(camelContext, java.util.Map.class, value)); return true; + case "tags": target.setTags(property(camelContext, java.lang.String.class, value)); return true; + default: return false; + } + } + + @Override + public Class getOptionType(String name, boolean ignoreCase) { + switch (ignoreCase ? name.toLowerCase() : name) { + case "description": return java.lang.String.class; + case "parameters": return java.util.Map.class; + case "tags": return java.lang.String.class; + default: return null; + } + } + + @Override + public Object getOptionValue(Object obj, String name, boolean ignoreCase) { + org.apache.camel.component.ai.tool.AiToolConfiguration target = (org.apache.camel.component.ai.tool.AiToolConfiguration) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "description": return target.getDescription(); + case "parameters": return target.getParameters(); + case "tags": return target.getTags(); + default: return null; + } + } + + @Override + public Object getCollectionValueType(Object target, String name, boolean ignoreCase) { + switch (ignoreCase ? name.toLowerCase() : name) { + case "parameters": return java.lang.String.class; + default: return null; + } + } +} + diff --git a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointConfigurer.java b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointConfigurer.java new file mode 100644 index 0000000000000..e799e69354da1 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointConfigurer.java @@ -0,0 +1,80 @@ +/* Generated by camel build tools - do NOT edit this file! */ +package org.apache.camel.component.ai.tool; + +import javax.annotation.processing.Generated; +import java.util.Map; + +import org.apache.camel.CamelContext; +import org.apache.camel.spi.ExtendedPropertyConfigurerGetter; +import org.apache.camel.spi.PropertyConfigurerGetter; +import org.apache.camel.spi.ConfigurerStrategy; +import org.apache.camel.spi.GeneratedPropertyConfigurer; +import org.apache.camel.util.CaseInsensitiveMap; +import org.apache.camel.support.component.PropertyConfigurerSupport; + +/** + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.EndpointSchemaGeneratorMojo") +@SuppressWarnings("unchecked") +public class AiToolEndpointConfigurer extends PropertyConfigurerSupport implements GeneratedPropertyConfigurer, PropertyConfigurerGetter { + + @Override + public boolean configure(CamelContext camelContext, Object obj, String name, Object value, boolean ignoreCase) { + AiToolEndpoint target = (AiToolEndpoint) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "bridgeerrorhandler": + case "bridgeErrorHandler": target.setBridgeErrorHandler(property(camelContext, boolean.class, value)); return true; + case "description": target.getConfiguration().setDescription(property(camelContext, java.lang.String.class, value)); return true; + case "exceptionhandler": + case "exceptionHandler": target.setExceptionHandler(property(camelContext, org.apache.camel.spi.ExceptionHandler.class, value)); return true; + case "exchangepattern": + case "exchangePattern": target.setExchangePattern(property(camelContext, org.apache.camel.ExchangePattern.class, value)); return true; + case "parameters": target.getConfiguration().setParameters(property(camelContext, java.util.Map.class, value)); return true; + case "tags": target.getConfiguration().setTags(property(camelContext, java.lang.String.class, value)); return true; + default: return false; + } + } + + @Override + public Class getOptionType(String name, boolean ignoreCase) { + switch (ignoreCase ? name.toLowerCase() : name) { + case "bridgeerrorhandler": + case "bridgeErrorHandler": return boolean.class; + case "description": return java.lang.String.class; + case "exceptionhandler": + case "exceptionHandler": return org.apache.camel.spi.ExceptionHandler.class; + case "exchangepattern": + case "exchangePattern": return org.apache.camel.ExchangePattern.class; + case "parameters": return java.util.Map.class; + case "tags": return java.lang.String.class; + default: return null; + } + } + + @Override + public Object getOptionValue(Object obj, String name, boolean ignoreCase) { + AiToolEndpoint target = (AiToolEndpoint) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "bridgeerrorhandler": + case "bridgeErrorHandler": return target.isBridgeErrorHandler(); + case "description": return target.getConfiguration().getDescription(); + case "exceptionhandler": + case "exceptionHandler": return target.getExceptionHandler(); + case "exchangepattern": + case "exchangePattern": return target.getExchangePattern(); + case "parameters": return target.getConfiguration().getParameters(); + case "tags": return target.getConfiguration().getTags(); + default: return null; + } + } + + @Override + public Object getCollectionValueType(Object target, String name, boolean ignoreCase) { + switch (ignoreCase ? name.toLowerCase() : name) { + case "parameters": return java.lang.String.class; + default: return null; + } + } +} + diff --git a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointUriFactory.java b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointUriFactory.java new file mode 100644 index 0000000000000..3bdaa5000b8c4 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointUriFactory.java @@ -0,0 +1,85 @@ +/* Generated by camel build tools - do NOT edit this file! */ +package org.apache.camel.component.ai.tool; + +import javax.annotation.processing.Generated; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.apache.camel.spi.EndpointUriFactory; + +/** + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.GenerateEndpointUriFactoryMojo") +public class AiToolEndpointUriFactory extends org.apache.camel.support.component.EndpointUriFactorySupport implements EndpointUriFactory { + + private static final String BASE = ":toolName"; + + private static final Set PROPERTY_NAMES; + private static final Set SECRET_PROPERTY_NAMES; + private static final Set ENDPOINT_IDENTITY_PROPERTY_NAMES; + private static final Map MULTI_VALUE_PREFIXES; + static { + Set props = new HashSet<>(7); + props.add("bridgeErrorHandler"); + props.add("description"); + props.add("exceptionHandler"); + props.add("exchangePattern"); + props.add("parameters"); + props.add("tags"); + props.add("toolName"); + PROPERTY_NAMES = Collections.unmodifiableSet(props); + SECRET_PROPERTY_NAMES = Collections.emptySet(); + ENDPOINT_IDENTITY_PROPERTY_NAMES = Collections.emptySet(); + Map prefixes = new HashMap<>(1); + prefixes.put("parameters", "parameter."); + MULTI_VALUE_PREFIXES = Collections.unmodifiableMap(prefixes); + } + + @Override + public boolean isEnabled(String scheme) { + return "ai-tool".equals(scheme); + } + + @Override + public String buildUri(String scheme, Map properties, boolean encode) throws URISyntaxException { + String syntax = scheme + BASE; + String uri = syntax; + + Map copy = new HashMap<>(properties); + + uri = buildPathParameter(syntax, uri, "toolName", null, true, copy); + uri = buildQueryParameters(uri, copy, encode); + return uri; + } + + @Override + public Set propertyNames() { + return PROPERTY_NAMES; + } + + @Override + public Set secretPropertyNames() { + return SECRET_PROPERTY_NAMES; + } + + @Override + public Set endpointIdentityPropertyNames() { + return ENDPOINT_IDENTITY_PROPERTY_NAMES; + } + + @Override + public Map multiValuePrefixes() { + return MULTI_VALUE_PREFIXES; + } + + @Override + public boolean isLenientProperties() { + return false; + } +} + diff --git a/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tool/ai-tool.json b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tool/ai-tool.json new file mode 100644 index 0000000000000..f0fd90ba449e6 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tool/ai-tool.json @@ -0,0 +1,43 @@ +{ + "component": { + "kind": "component", + "name": "ai-tool", + "title": "AI Tool", + "description": "Framework-agnostic consumer endpoint that registers a Camel route as an LLM tool in the shared AiToolRegistry.", + "deprecated": false, + "firstVersion": "4.22.0", + "label": "ai", + "javaType": "org.apache.camel.component.ai.tool.AiToolComponent", + "supportLevel": "Preview", + "groupId": "org.apache.camel", + "artifactId": "camel-ai-tool", + "version": "4.22.0-SNAPSHOT", + "scheme": "ai-tool", + "extendsScheme": "", + "syntax": "ai-tool:toolName", + "async": false, + "api": false, + "consumerOnly": true, + "producerOnly": false, + "lenientProperties": false, + "browsable": false, + "remote": false + }, + "componentProperties": { + "bridgeErrorHandler": { "index": 0, "kind": "property", "displayName": "Bridge Error Handler", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Allows for bridging the consumer to the Camel routing Error Handler, which mean any exceptions (if possible) occurred while the Camel consumer is trying to pickup incoming messages, or the likes, will now be processed as a message and handled by the routing Error Handler. Important: This is only possible if the 3rd party component allows Camel to be alerted if an exception was thrown. Some components handle this internally only, and therefore bridgeErrorHandler is not possible. In other situations we may improve the Camel component to hook into the 3rd party component and make this possible for future releases. By default the consumer will use the org.apache.camel.spi.ExceptionHandler to deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, + "configuration": { "index": 1, "kind": "property", "displayName": "Configuration", "group": "consumer", "label": "", "required": false, "type": "object", "javaType": "org.apache.camel.component.ai.tool.AiToolConfiguration", "deprecated": false, "autowired": false, "secret": false, "description": "The component configuration" }, + "description": { "index": 2, "kind": "property", "displayName": "Description", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Human-readable description of what this tool does. Passed verbatim to the LLM; be precise and action-oriented. When omitted, defaults to the tool name." }, + "parameters": { "index": 3, "kind": "property", "displayName": "Parameters", "group": "consumer", "label": "consumer", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "parameter.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Tool input parameters. Format: parameter.NAME=TYPE, parameter.NAME.description=TEXT, parameter.NAME.required=true or false, parameter.NAME.enum=val1,val2. Supported types: string, integer, number, boolean. This is a multi-value option with prefix: parameter." }, + "tags": { "index": 4, "kind": "property", "displayName": "Tags", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Comma-separated list of tags used to group tools. Producers filter the registry by these tags to select which tools to expose to the LLM. When omitted, the tool goes into a default pool available to all producers." }, + "autowiredEnabled": { "index": 5, "kind": "property", "displayName": "Autowired Enabled", "group": "advanced", "label": "advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": true, "description": "Whether autowiring is enabled. This is used for automatic autowiring options (the option must be marked as autowired) by looking up in the registry to find if there is a single instance of matching type, which then gets configured on the component. This can be used for automatic configuring JDBC data sources, JMS connection factories, AWS Clients, etc." } + }, + "properties": { + "toolName": { "index": 0, "kind": "path", "displayName": "Tool Name", "group": "consumer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The tool name. This is the name the LLM sees and uses to invoke the tool." }, + "description": { "index": 1, "kind": "parameter", "displayName": "Description", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Human-readable description of what this tool does. Passed verbatim to the LLM; be precise and action-oriented. When omitted, defaults to the tool name." }, + "parameters": { "index": 2, "kind": "parameter", "displayName": "Parameters", "group": "consumer", "label": "consumer", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "parameter.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Tool input parameters. Format: parameter.NAME=TYPE, parameter.NAME.description=TEXT, parameter.NAME.required=true or false, parameter.NAME.enum=val1,val2. Supported types: string, integer, number, boolean. This is a multi-value option with prefix: parameter." }, + "tags": { "index": 3, "kind": "parameter", "displayName": "Tags", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Comma-separated list of tags used to group tools. Producers filter the registry by these tags to select which tools to expose to the LLM. When omitted, the tool goes into a default pool available to all producers." }, + "bridgeErrorHandler": { "index": 4, "kind": "parameter", "displayName": "Bridge Error Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Allows for bridging the consumer to the Camel routing Error Handler, which mean any exceptions (if possible) occurred while the Camel consumer is trying to pickup incoming messages, or the likes, will now be processed as a message and handled by the routing Error Handler. Important: This is only possible if the 3rd party component allows Camel to be alerted if an exception was thrown. Some components handle this internally only, and therefore bridgeErrorHandler is not possible. In other situations we may improve the Camel component to hook into the 3rd party component and make this possible for future releases. By default the consumer will use the org.apache.camel.spi.ExceptionHandler to deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, + "exceptionHandler": { "index": 5, "kind": "parameter", "displayName": "Exception Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "object", "javaType": "org.apache.camel.spi.ExceptionHandler", "optionalPrefix": "consumer.", "deprecated": false, "autowired": false, "secret": false, "description": "To let the consumer use a custom ExceptionHandler. Notice if the option bridgeErrorHandler is enabled then this option is not in use. By default the consumer will deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, + "exchangePattern": { "index": 6, "kind": "parameter", "displayName": "Exchange Pattern", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "enum", "javaType": "org.apache.camel.ExchangePattern", "enum": [ "InOnly", "InOut" ], "deprecated": false, "autowired": false, "secret": false, "description": "Sets the exchange pattern when the consumer creates an exchange." } + } +} diff --git a/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/component.properties b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/component.properties new file mode 100644 index 0000000000000..b71c1440c3aa4 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/component.properties @@ -0,0 +1,7 @@ +# Generated by camel build tools - do NOT edit this file! +components=ai-tool +groupId=org.apache.camel +artifactId=camel-ai-tool +version=4.22.0-SNAPSHOT +projectName=Camel :: AI :: Tool +projectDescription=Framework-agnostic Camel AI tool abstraction for route-based LLM tools diff --git a/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/component/ai-tool b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/component/ai-tool new file mode 100644 index 0000000000000..56073f9900c5b --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/component/ai-tool @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.component.ai.tool.AiToolComponent diff --git a/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/configurer/ai-tool-component b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/configurer/ai-tool-component new file mode 100644 index 0000000000000..e0c10f1d87ef6 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/configurer/ai-tool-component @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.component.ai.tool.AiToolComponentConfigurer diff --git a/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/configurer/ai-tool-endpoint b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/configurer/ai-tool-endpoint new file mode 100644 index 0000000000000..adf891892a7b6 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/configurer/ai-tool-endpoint @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.component.ai.tool.AiToolEndpointConfigurer diff --git a/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.ai.tool.AiToolConfiguration b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.ai.tool.AiToolConfiguration new file mode 100644 index 0000000000000..1b2a0c9a2a547 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.ai.tool.AiToolConfiguration @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.component.ai.tool.AiToolConfigurationConfigurer diff --git a/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/urifactory/ai-tool-endpoint b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/urifactory/ai-tool-endpoint new file mode 100644 index 0000000000000..ebf1720ccff89 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/urifactory/ai-tool-endpoint @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.component.ai.tool.AiToolEndpointUriFactory 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 new file mode 100644 index 0000000000000..e478e135d0972 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc @@ -0,0 +1,219 @@ += AI Tool Component +:doctitle: AI Tool +:shortname: ai-tool +:artifactid: camel-ai-tool +:description: Framework-agnostic consumer endpoint that registers a Camel route as an LLM tool in the shared AiToolRegistry. +:since: 4.22 +:supportlevel: Preview +:tabs-sync-option: +:component-header: Only consumer is supported +//Manually maintained attributes +:group: AI + +*Since Camel {since}* + +*{component-header}* + +The AI Tool component exposes Camel routes as tools that AI models can call. +Any AI producer component (such as xref:langchain4j-agent-component.adoc[LangChain4j Agent] or +xref:spring-ai-chat-component.adoc[Spring AI Chat]) discovers registered tools using tag-based filtering. + +Maven users will need to add the following dependency to their `pom.xml` +for this component: + +[source,xml] +---- + + org.apache.camel + camel-ai-tool + x.x.x + + +---- + +== URI format + +---- +ai-tool:toolName[?options] +---- + +Where *toolName* is the name the LLM sees and uses to invoke the tool. + +// component options: START +include::partial$component-configure-options.adoc[] +include::partial$component-endpoint-options.adoc[] +include::partial$component-endpoint-headers.adoc[] +// component options: END + +== Usage + +=== Basic Tool Definition + +[tabs] +==== +Java:: ++ +[source,java] +---- +from("ai-tool:weather?tags=weather&description=Get current weather for a city" + + "¶meter.city=string¶meter.city.description=The city name") + .to("bean:weatherService"); +---- + +XML:: ++ +[source,xml] +---- + + + + +---- + +YAML:: ++ +[source,yaml] +---- +- route: + from: + uri: ai-tool:weather + parameters: + tags: weather + description: "Get current weather for a city" + parameter.city: string + parameter.city.description: "The city name" + steps: + - to: + uri: bean:weatherService +---- +==== + +=== Tool with Multiple Parameters + +[tabs] +==== +Java:: ++ +[source,java] +---- +from("ai-tool:calculator?tags=math" + + "&description=Calculate a math expression" + + "¶meter.a=number" + + "¶meter.a.description=First operand" + + "¶meter.a.required=true" + + "¶meter.b=number" + + "¶meter.b.description=Second operand" + + "¶meter.b.required=true" + + "¶meter.operation=string" + + "¶meter.operation.description=The operation to perform" + + "¶meter.operation.enum=add,subtract,multiply,divide") + .to("direct:calculator"); +---- + +XML:: ++ +[source,xml] +---- + + + + +---- + +YAML:: ++ +[source,yaml] +---- +- route: + from: + uri: ai-tool:calculator + parameters: + tags: math + description: "Calculate a math expression" + parameter.a: number + parameter.a.description: "First operand" + parameter.a.required: true + parameter.b: number + parameter.b.description: "Second operand" + parameter.b.required: true + parameter.operation: string + parameter.operation.description: "The operation to perform" + parameter.operation.enum: "add,subtract,multiply,divide" + steps: + - to: + uri: direct:calculator +---- +==== + +=== Tag-Based Discovery + +Tags group tools so that AI producers can select relevant subsets. + +[tabs] +==== +Java:: ++ +[source,java] +---- +// Define tools with tags +from("ai-tool:weather?tags=weather,external-api&description=Get weather for a city" + + "¶meter.city=string¶meter.city.description=The city name") + .to("bean:weatherService"); + +from("ai-tool:lookupUser?tags=users&description=Look up a user by ID" + + "¶meter.id=integer¶meter.id.description=The user ID¶meter.id.required=true") + .to("bean:userService?method=findById(${header.id})"); +---- + +XML:: ++ +[source,xml] +---- + + + + + + + + + + +---- + +YAML:: ++ +[source,yaml] +---- +# Define tools with tags +- route: + from: + uri: ai-tool:weather + parameters: + tags: weather,external-api + description: "Get weather for a city" + parameter.city: string + parameter.city.description: "The city name" + steps: + - to: + uri: bean:weatherService + +- route: + from: + uri: ai-tool:lookupUser + parameters: + tags: users + description: "Look up a user by ID" + parameter.id: integer + parameter.id.description: "The user ID" + parameter.id.required: true + steps: + - to: + uri: "bean:userService?method=findById(${header.id})" +---- +==== + +== See Also + +* xref:langchain4j-agent-component.adoc[LangChain4j Agent Component] — will discover ai-tool tools in a future release +* xref:spring-ai-chat-component.adoc[Spring AI Chat Component] — will discover ai-tool tools in a future release diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiTool.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiTool.java new file mode 100644 index 0000000000000..b77053684b801 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiTool.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.component.ai.tool; + +/** + * Constants for the AI Tool component. + * + * @since 4.22 + */ +public final class AiTool { + + public static final String SCHEME = "ai-tool"; + + private AiTool() { + } +} diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolComponent.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolComponent.java new file mode 100644 index 0000000000000..da179d5f03414 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolComponent.java @@ -0,0 +1,86 @@ +/* + * 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.Map; +import java.util.stream.Collectors; + +import org.apache.camel.CamelContext; +import org.apache.camel.Endpoint; +import org.apache.camel.spi.Metadata; +import org.apache.camel.spi.annotations.Component; +import org.apache.camel.support.DefaultComponent; +import org.apache.camel.util.ObjectHelper; +import org.apache.camel.util.PropertiesHelper; + +import static org.apache.camel.component.ai.tool.AiTool.SCHEME; + +/** + * Camel component that registers routes as LLM-callable tools in the shared {@link AiToolRegistry}. + * + * @since 4.22 + */ +@Component(SCHEME) +public class AiToolComponent extends DefaultComponent { + + @Metadata(description = "The component configuration") + private AiToolConfiguration configuration; + + public AiToolComponent() { + this(null); + } + + public AiToolComponent(CamelContext context) { + super(context); + this.configuration = new AiToolConfiguration(); + } + + @Override + protected Endpoint createEndpoint(String uri, String remaining, Map parameters) throws Exception { + if (ObjectHelper.isEmpty(remaining)) { + throw new IllegalArgumentException( + "A toolName must be provided: ai-tool:?description="); + } + if (remaining.contains("/")) { + throw new IllegalArgumentException( + "Tool name must not contain '/': ai-tool:?description="); + } + + final String toolName = remaining; + + AiToolConfiguration config = this.configuration.copy(); + + Map toolParameters = PropertiesHelper.extractProperties(parameters, "parameter."); + if (!toolParameters.isEmpty()) { + config.setParameters(toolParameters.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().toString()))); + } + + AiToolEndpoint endpoint = new AiToolEndpoint(uri, this, toolName, config); + + setProperties(endpoint, parameters); + return endpoint; + } + + public AiToolConfiguration getConfiguration() { + return configuration; + } + + public void setConfiguration(AiToolConfiguration configuration) { + this.configuration = configuration; + } +} diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConfiguration.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConfiguration.java new file mode 100644 index 0000000000000..c747389518088 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConfiguration.java @@ -0,0 +1,95 @@ +/* + * 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.HashMap; +import java.util.Map; + +import org.apache.camel.RuntimeCamelException; +import org.apache.camel.spi.Configurer; +import org.apache.camel.spi.Metadata; +import org.apache.camel.spi.UriParam; +import org.apache.camel.spi.UriParams; + +/** + * Configuration for the {@link AiToolComponent}: tool description, tags, and parameter metadata. + * + * @since 4.22 + */ +@Configurer +@UriParams +public class AiToolConfiguration implements Cloneable { + + @Metadata(label = "consumer") + @UriParam(description = "Comma-separated list of tags used to group tools. " + + "Producers filter the registry by these tags to select which tools to expose to the LLM. " + + "When omitted, the tool goes into a default pool available to all producers.") + private String tags; + + @Metadata(label = "consumer") + @UriParam(description = "Human-readable description of what this tool does. " + + "Passed verbatim to the LLM; be precise and action-oriented. " + + "When omitted, defaults to the tool name.") + private String description; + + @Metadata(label = "consumer") + @UriParam(description = "Tool input parameters. " + + "Format: parameter.NAME=TYPE, parameter.NAME.description=TEXT, " + + "parameter.NAME.required=true or false, parameter.NAME.enum=val1,val2. " + + "Supported types: string, integer, number, boolean.", + prefix = "parameter.", multiValue = true) + private Map parameters; + + public AiToolConfiguration() { + } + + public String getTags() { + return tags; + } + + public void setTags(String tags) { + this.tags = tags; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Map getParameters() { + return parameters; + } + + public void setParameters(Map parameters) { + this.parameters = parameters; + } + + public AiToolConfiguration copy() { + try { + AiToolConfiguration copy = (AiToolConfiguration) super.clone(); + if (this.parameters != null) { + copy.parameters = new HashMap<>(this.parameters); + } + return copy; + } catch (CloneNotSupportedException e) { + throw new RuntimeCamelException(e); + } + } +} diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConsumer.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConsumer.java new file mode 100644 index 0000000000000..0b07f7c258215 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConsumer.java @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.ai.tool; + +import java.util.Map; + +import org.apache.camel.Processor; +import org.apache.camel.support.DefaultConsumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Consumer that registers a Camel route as an AI tool in the {@link AiToolRegistry} on start and deregisters on stop. + * + * @since 4.22 + */ +public class AiToolConsumer extends DefaultConsumer { + + private static final Logger LOG = LoggerFactory.getLogger(AiToolConsumer.class); + + private final String toolName; + private final AiToolConfiguration configuration; + private AiToolSpec registeredSpec; + private String[] registeredTags; + private boolean registeredInDefaultPool; + + public AiToolConsumer(AiToolEndpoint endpoint, Processor processor) { + super(endpoint, processor); + this.toolName = endpoint.getToolName(); + this.configuration = endpoint.getConfiguration(); + } + + @Override + protected void doStart() throws Exception { + super.doStart(); + + Map params = configuration.getParameters(); + Map parameterDefs + = (params != null && !params.isEmpty()) + ? AiToolParameterHelper.parseParameterMetadata(params) + : Map.of(); + + String jsonSchema = !parameterDefs.isEmpty() + ? AiToolParameterHelper.buildJsonSchemaFromDefs(parameterDefs) + : null; + + String desc = configuration.getDescription(); + if (desc == null || desc.isBlank()) { + desc = toolName; + } + + registeredSpec = new AiToolSpec( + toolName, desc, parameterDefs, jsonSchema, this); + + String tags = configuration.getTags(); + String[] parsedTags = (tags != null && !tags.isBlank()) + ? AiToolParameterHelper.splitTags(tags) + : null; + if (parsedTags != null && parsedTags.length > 0) { + registeredTags = parsedTags; + registeredInDefaultPool = false; + } else { + registeredTags = null; + registeredInDefaultPool = true; + } + + register(); + } + + @Override + protected void doSuspend() throws Exception { + if (registeredSpec != null) { + deregister(); + } + super.doSuspend(); + } + + @Override + protected void doResume() throws Exception { + super.doResume(); + if (registeredSpec != null) { + register(); + } + } + + @Override + protected void doStop() throws Exception { + if (registeredSpec != null) { + deregister(); + registeredSpec = null; + registeredTags = null; + registeredInDefaultPool = false; + } + super.doStop(); + } + + private void register() { + AiToolRegistry registry = AiToolRegistry.getOrCreate(getEndpoint().getCamelContext()); + if (registeredTags != null) { + for (String tag : registeredTags) { + LOG.debug("Registering tool '{}' with tag '{}'", toolName, tag); + registry.put(tag, registeredSpec); + } + } else if (registeredInDefaultPool) { + LOG.debug("Registering tool '{}' in default pool (no tags)", toolName); + registry.putDefault(registeredSpec); + } + } + + private void deregister() { + AiToolRegistry registry = AiToolRegistry.getOrCreate(getEndpoint().getCamelContext()); + if (registeredTags != null) { + for (String tag : registeredTags) { + LOG.debug("Removing tool '{}' from tag '{}'", registeredSpec.getName(), tag); + registry.remove(tag, registeredSpec); + } + } else if (registeredInDefaultPool) { + LOG.debug("Removing tool '{}' from default pool", registeredSpec.getName()); + registry.removeDefault(registeredSpec); + } + } +} diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolEndpoint.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolEndpoint.java new file mode 100644 index 0000000000000..58eca91e719b9 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolEndpoint.java @@ -0,0 +1,87 @@ +/* + * 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 org.apache.camel.Category; +import org.apache.camel.Consumer; +import org.apache.camel.Processor; +import org.apache.camel.Producer; +import org.apache.camel.spi.Metadata; +import org.apache.camel.spi.UriEndpoint; +import org.apache.camel.spi.UriParam; +import org.apache.camel.spi.UriPath; +import org.apache.camel.support.DefaultEndpoint; + +import static org.apache.camel.component.ai.tool.AiTool.SCHEME; + +/** + * Framework-agnostic consumer endpoint that registers a Camel route as an LLM tool in the shared + * {@link AiToolRegistry}. + * + * @since 4.22 + */ +@UriEndpoint( + firstVersion = "4.22.0", + scheme = SCHEME, + title = "AI Tool", + syntax = "ai-tool:toolName", + consumerOnly = true, + remote = false, + category = { Category.AI }) +public class AiToolEndpoint extends DefaultEndpoint { + + @Metadata(required = true) + @UriPath(description = "The tool name. This is the name the LLM sees and uses to invoke the tool.") + private final String toolName; + + @UriParam(description = "Tool configuration including tags, description, and parameter definitions.") + private AiToolConfiguration configuration; + + public AiToolEndpoint(String uri, AiToolComponent component, String toolName, + AiToolConfiguration configuration) { + super(uri, component); + this.toolName = toolName; + this.configuration = configuration; + } + + @Override + public Producer createProducer() { + throw new UnsupportedOperationException( + "ai-tool does not support producer mode. " + + "Use a framework-specific component (langchain4j-tools, spring-ai-chat, openai) " + + "with a matching tags parameter to invoke tools."); + } + + @Override + public Consumer createConsumer(Processor processor) throws Exception { + AiToolConsumer consumer = new AiToolConsumer(this, processor); + configureConsumer(consumer); + return consumer; + } + + public String getToolName() { + return toolName; + } + + public AiToolConfiguration getConfiguration() { + return configuration; + } + + public void setConfiguration(AiToolConfiguration configuration) { + this.configuration = configuration; + } +} 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 new file mode 100644 index 0000000000000..5b61d3cab8da1 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java @@ -0,0 +1,160 @@ +/* + * 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.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.apache.camel.Exchange; +import org.apache.camel.Processor; +import org.apache.camel.support.DefaultConsumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Framework-agnostic executor for Camel route tools. Handles the common logic of resolving the route processor from the + * tool's consumer, populating an {@link Exchange} with tool arguments, invoking the route, and returning the result. + *

+ * AI framework adapters (LangChain4j, Spring AI, OpenAI) only need to parse their native argument format into a + * {@code Map} and call this executor — they do not need to know how routes are resolved or invoked. + *

+ * Returns an {@link AiToolResult} that classifies the outcome without deciding error handling policy. Framework + * adapters inspect the result type and decide whether to return the error message as a string to the LLM, rethrow the + * cause so framework-level error handlers fire, or sanitize the message before returning it. + *

+ * This is an internal support class used by Camel AI framework adapters and is not intended for direct use by end + * users. + * + * @since 4.22 + */ +public final class AiToolExecutor { + + private static final Logger LOG = LoggerFactory.getLogger(AiToolExecutor.class); + + private AiToolExecutor() { + } + + /** + * Executes a Camel route tool by resolving the route processor from the spec's consumer, populating the exchange + * with the provided arguments, and invoking the route. + *

+ * Arguments are validated against the tool's declared parameters (undeclared arguments are filtered out with a + * warning, required arguments are checked). Each argument is set as an individual exchange header so that route + * expressions like {@code ${header.city}} and SQL bindings work naturally. Argument names that start with + * {@code camel} or {@code org.apache.camel.} (case-insensitive) are rejected to prevent collision with internal + * Camel headers (following the same pattern as the A2A component). + *

+ * The calling adapter owns the exchange lifecycle: it must create the exchange before calling this method and + * release it afterwards (via {@code consumer.releaseExchange()}) in a try-finally block. + *

+ * All errors — validation failures and route execution errors — are caught and returned as typed + * {@link AiToolResult} variants rather than propagated. Framework adapters inspect the result type and decide how + * to handle errors (return to LLM, rethrow, sanitize). + * + * @param spec the tool specification containing the consumer and declared parameters + * @param arguments the tool arguments as a name-value map; each framework adapter is responsible for parsing its + * native format (JSON string, Map, etc.) into this map before calling + * @param exchange the Camel exchange to populate with arguments and execute + * @return an {@link AiToolResult} classifying the outcome; never null + */ + public static AiToolResult execute(AiToolSpec spec, Map arguments, Exchange exchange) { + String toolName = spec.getName(); + + DefaultConsumer consumer = spec.getConsumer(); + if (consumer == null) { + IllegalStateException cause = new IllegalStateException( + String.format("No consumer available for tool '%s'", toolName)); + return new AiToolResult.ExecutionError(cause.getMessage(), cause); + } + + Processor routeProcessor = consumer.getProcessor(); + if (routeProcessor == null) { + IllegalStateException cause = new IllegalStateException( + String.format("No route processor available for tool '%s'", toolName)); + return new AiToolResult.ExecutionError(cause.getMessage(), cause); + } + + LOG.debug("Executing Camel route tool: '{}'", toolName); + + // Defensive copy so callers cannot mutate arguments during route execution + Map argsCopy = arguments != null ? new HashMap<>(arguments) : new HashMap<>(); + + // Filter out undeclared arguments -- LLMs frequently hallucinate extra parameters + // and the generated schema advertises additionalProperties: false. + if (!argsCopy.isEmpty() && !spec.getParameterDefs().isEmpty()) { + Set declaredParams = spec.getParameterDefs().keySet(); + + argsCopy.keySet().removeIf(name -> { + if (!declaredParams.contains(name)) { + LOG.warn("Undeclared tool argument '{}' for tool '{}' -- the LLM sent a parameter " + + "that is not declared in the tool specification; filtering it out", + name, toolName); + return true; + } + return false; + }); + } + + for (Map.Entry entry : spec.getParameterDefs().entrySet()) { + if (entry.getValue().isRequired() + && (arguments == null || !arguments.containsKey(entry.getKey()))) { + LOG.warn("Missing required argument '{}' for tool '{}' -- the LLM did not send " + + "a parameter that is declared as required in the tool specification", + entry.getKey(), toolName); + IllegalArgumentException cause = new IllegalArgumentException( + String.format("Missing required argument '%s' for tool '%s'", entry.getKey(), toolName)); + return new AiToolResult.ArgumentError(cause.getMessage(), cause); + } + } + // Set each argument as an exchange header so route expressions (${header.city}) + // and SQL bindings work naturally. Filter out Camel-internal names to prevent + // header-namespace injection (CVE-2025-27636 family). + for (Map.Entry entry : argsCopy.entrySet()) { + String name = entry.getKey(); + String lower = name.toLowerCase(Locale.ROOT); + if (lower.startsWith("camel") || lower.startsWith("org.apache.camel.")) { + LOG.warn("Rejecting tool argument '{}' for tool '{}' -- argument names starting with " + + "'Camel' or 'org.apache.camel.' are reserved for internal use", + name, toolName); + continue; + } + exchange.getMessage().setHeader(name, entry.getValue()); + } + + // Execute the route + try { + routeProcessor.process(exchange); + + if (exchange.getException() != null) { + Exception routeError = exchange.getException(); + LOG.error("Error executing tool '{}': {}", toolName, routeError.getMessage(), routeError); + return new AiToolResult.ExecutionError( + String.format("Error executing tool '%s': %s", toolName, routeError.getMessage()), routeError); + } + + String result = exchange.getMessage().getBody(String.class); + LOG.debug("Tool '{}' execution completed successfully", toolName); + return new AiToolResult.Success(result != null ? result : "No result"); + } catch (Exception e) { + LOG.error("Error executing tool '{}': {}", toolName, e.getMessage(), e); + return new AiToolResult.ExecutionError( + String.format("Error executing tool '%s': %s", toolName, e.getMessage()), e); + } + } +} diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolParameterHelper.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolParameterHelper.java new file mode 100644 index 0000000000000..95732b14d1de6 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolParameterHelper.java @@ -0,0 +1,232 @@ +/* + * 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.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Shared utilities for parsing tool parameter metadata and building JSON Schema. Replaces the duplicated + * {@code TagsHelper} and {@code parseParameterMetadata()} logic from {@code camel-langchain4j-tools} and + * {@code camel-spring-ai-tools}. + * + * @since 4.22 + */ +public final class AiToolParameterHelper { + + private static final Logger LOG = LoggerFactory.getLogger(AiToolParameterHelper.class); + + private AiToolParameterHelper() { + } + + /** + * Splits a comma-separated tag list into individual tags. + */ + public static String[] splitTags(String tagList) { + if (tagList == null || tagList.isBlank()) { + return new String[0]; + } + return Arrays.stream(tagList.trim().split("\\s*,\\s*")) + .filter(s -> !s.isEmpty()) + .toArray(String[]::new); + } + + /** + * Parses a flat parameter map (as received from URI or endpoint config) into structured {@link ParameterDef} + * objects. + *

+ * Handles entries like: + *

    + *
  • {@code city=string} — defines parameter type
  • + *
  • {@code city.description=The city name} — adds description
  • + *
  • {@code city.required=true} — marks as required
  • + *
  • {@code unit.enum=celsius,fahrenheit} — defines allowed values
  • + *
+ */ + public static Map parseParameterMetadata(Map parameters) { + Map metadata = new HashMap<>(); + + for (Map.Entry entry : parameters.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + + if (key.contains(".")) { + String[] parts = key.split("\\.", 2); + String paramName = parts[0]; + String propertyName = parts[1]; + ParameterDef def = metadata.computeIfAbsent(paramName, k -> new ParameterDef()); + + switch (propertyName) { + case "description" -> def.setDescription(value); + case "required" -> def.setRequired(Boolean.parseBoolean(value)); + case "enum" -> def.setEnumValues( + Arrays.stream(value.split(",")).map(String::trim).filter(s -> !s.isEmpty()).toList()); + default -> LOG.warn("Unknown parameter property '{}' for parameter '{}' -- " + + "supported properties are: description, required, enum", + propertyName, paramName); + } + } else { + metadata.computeIfAbsent(key, k -> new ParameterDef()).setType(value); + } + } + + return metadata; + } + + /** + * Builds a JSON Schema object string from the flat parameter map. The result conforms to JSON Schema and is + * understood by Spring AI ({@code inputSchema}) and OpenAI ({@code function.parameters}). + */ + public static String buildJsonSchema(Map parameters) { + return buildJsonSchemaFromDefs(parseParameterMetadata(parameters)); + } + + /** + * Builds a JSON Schema object string from pre-parsed parameter definitions. Use this method when + * {@link #parseParameterMetadata(Map)} has already been called to avoid re-parsing. + */ + public static String buildJsonSchemaFromDefs(Map defs) { + JsonObject schema = new JsonObject(); + schema.put("type", "object"); + + JsonObject properties = new JsonObject(); + List required = new ArrayList<>(); + + for (Map.Entry entry : defs.entrySet()) { + String name = entry.getKey(); + ParameterDef def = entry.getValue(); + + JsonObject prop = new JsonObject(); + prop.put("type", mapType(def.getType())); + + if (def.getDescription() != null) { + prop.put("description", def.getDescription()); + } + if (def.getEnumValues() != null && !def.getEnumValues().isEmpty()) { + JsonArray enumArray = new JsonArray(); + enumArray.addAll(def.getEnumValues()); + prop.put("enum", enumArray); + } + if (def.isRequired()) { + required.add(name); + } + properties.put(name, prop); + } + + schema.put("properties", properties); + + if (!required.isEmpty()) { + JsonArray requiredArray = new JsonArray(); + requiredArray.addAll(required); + schema.put("required", requiredArray); + } + + schema.put("additionalProperties", false); + + return schema.toJson(); + } + + private static String mapType(String type) { + if (type == null) { + return "string"; + } + return switch (type.toLowerCase(Locale.ROOT)) { + case "integer", "int", "long" -> "integer"; + case "number", "double", "float" -> "number"; + case "boolean", "bool" -> "boolean"; + default -> "string"; + }; + } + + /** + * Holds structured metadata for a single tool parameter. + */ + public static class ParameterDef { + private String type = "string"; + private String description; + private boolean required; + private List enumValues; + + public String getType() { + return type; + } + + private void setType(String type) { + this.type = type; + } + + public String getDescription() { + return description; + } + + private void setDescription(String description) { + this.description = description; + } + + public boolean isRequired() { + return required; + } + + private void setRequired(boolean required) { + this.required = required; + } + + public List getEnumValues() { + return enumValues; + } + + private void setEnumValues(List enumValues) { + this.enumValues = enumValues != null ? List.copyOf(enumValues) : null; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ParameterDef that = (ParameterDef) o; + return required == that.required + && Objects.equals(type, that.type) + && Objects.equals(description, that.description) + && Objects.equals(enumValues, that.enumValues); + } + + @Override + public int hashCode() { + return Objects.hash(type, description, required, enumValues); + } + + @Override + public String toString() { + return "ParameterDef{type=" + type + ", description=" + description + + ", required=" + required + ", enumValues=" + enumValues + '}'; + } + } +} diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistry.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistry.java new file mode 100644 index 0000000000000..c47562fd820c6 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistry.java @@ -0,0 +1,184 @@ +/* + * 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.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.camel.CamelContext; + +/** + * CamelContext-scoped registry mapping tags to {@link AiToolSpec} instances. AI components (LangChain4j, Spring AI) + * read from this registry to discover tools registered via the {@code ai-tool} consumer endpoint. + *

+ * 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. + *

+ * 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 ReentrantLock FACTORY_LOCK = new ReentrantLock(); + + private final ReentrantLock lock = new ReentrantLock(); + private final Map> tools; + private final Set defaultTools; + + AiToolRegistry() { + tools = new HashMap<>(); + defaultTools = new LinkedHashSet<>(); + } + + /** + * Returns the {@link AiToolRegistry} for the given {@link CamelContext}, creating and registering one as a context + * plugin if it does not yet exist. + */ + public static AiToolRegistry getOrCreate(CamelContext context) { + FACTORY_LOCK.lock(); + try { + AiToolRegistry registry = context.getCamelContextExtension() + .getContextPlugin(AiToolRegistry.class); + if (registry == null) { + registry = new AiToolRegistry(); + context.getCamelContextExtension() + .addContextPlugin(AiToolRegistry.class, registry); + } + return registry; + } finally { + FACTORY_LOCK.unlock(); + } + } + + public void put(String tag, AiToolSpec spec) { + lock.lock(); + try { + Set set = tools.computeIfAbsent(tag, k -> new LinkedHashSet<>()); + for (AiToolSpec existing : set) { + if (existing.getName().equals(spec.getName()) && existing != spec) { + throw new IllegalArgumentException( + "Duplicate tool name '" + spec.getName() + "' under tag '" + tag + + "': tool names must be unique per tag"); + } + } + set.add(spec); + } finally { + lock.unlock(); + } + } + + public void remove(String tag, AiToolSpec spec) { + lock.lock(); + try { + Set set = tools.get(tag); + if (set != null) { + set.remove(spec); + if (set.isEmpty()) { + tools.remove(tag); + } + } + } finally { + lock.unlock(); + } + } + + public void putDefault(AiToolSpec spec) { + lock.lock(); + try { + for (AiToolSpec existing : defaultTools) { + if (existing.getName().equals(spec.getName()) && existing != spec) { + throw new IllegalArgumentException( + "Duplicate tool name '" + spec.getName() + + "' in the default pool: tool names must be unique"); + } + } + defaultTools.add(spec); + } finally { + lock.unlock(); + } + } + + public void removeDefault(AiToolSpec spec) { + lock.lock(); + try { + defaultTools.remove(spec); + } finally { + lock.unlock(); + } + } + + /** + * Returns tools registered for a specific tag, merged with the default pool (tools with no tags). + */ + public Set getToolsByTag(String tag) { + lock.lock(); + try { + Set result = new LinkedHashSet<>(defaultTools); + Set tagTools = tools.get(tag); + if (tagTools != null) { + result.addAll(tagTools); + } + return result; + } finally { + lock.unlock(); + } + } + + /** + * Returns all tools across all tags and the default pool. + */ + public Set getAllTools() { + lock.lock(); + try { + Set result = new LinkedHashSet<>(defaultTools); + for (Set tagTools : tools.values()) { + result.addAll(tagTools); + } + return result; + } finally { + lock.unlock(); + } + } + + public Map> getTools() { + lock.lock(); + try { + Map> snapshot = new LinkedHashMap<>(); + for (Map.Entry> entry : tools.entrySet()) { + snapshot.put(entry.getKey(), new LinkedHashSet<>(entry.getValue())); + } + return Map.copyOf(snapshot); + } finally { + lock.unlock(); + } + } + + public Set getDefaultTools() { + lock.lock(); + try { + return new LinkedHashSet<>(defaultTools); + } finally { + lock.unlock(); + } + } +} diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolResult.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolResult.java new file mode 100644 index 0000000000000..0eb750db9dfb0 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolResult.java @@ -0,0 +1,59 @@ +/* + * 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; + +/** + * Result of a tool execution by {@link AiToolExecutor}. Classifies the outcome without deciding error handling policy. + * Framework adapters (LangChain4j, Spring AI, OpenAI) inspect the result type and decide whether to return the error as + * a string to the LLM, rethrow the cause so framework-level error handlers fire, or sanitize the message before + * returning it. + *

+ * Security note: {@link ExecutionError#message()} may contain raw exception messages from route execution, which + * can include internal details (file paths, database errors, class names). Framework adapters MUST NOT return this + * message verbatim to the LLM without sanitization. Use a generic message (e.g., "Tool execution failed") for the LLM + * response and log the detailed cause internally. + * + * @since 4.22 + */ +public sealed interface AiToolResult { + + /** + * The route executed successfully and produced a result. + * + * @param value the route result as a string + */ + record Success(String value) implements AiToolResult { + } + + /** + * The tool call failed before reaching the route due to a missing required argument. + * + * @param message a human-readable description of the validation failure + * @param cause the underlying exception + */ + record ArgumentError(String message, Exception cause) implements AiToolResult { + } + + /** + * The route processor threw an exception or the exchange carries an exception after processing. + * + * @param message a human-readable description of the execution failure + * @param cause the underlying exception + */ + record ExecutionError(String message, Exception cause) implements AiToolResult { + } +} diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolSpec.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolSpec.java new file mode 100644 index 0000000000000..dbecd4f7d41c4 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolSpec.java @@ -0,0 +1,112 @@ +/* + * 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.Collections; +import java.util.Map; +import java.util.Objects; + +import org.apache.camel.support.DefaultConsumer; + +/** + * Framework-agnostic description of a Camel route registered as an LLM tool. + *

+ * Stores both a structured {@link AiToolParameterHelper.ParameterDef} map (used by LangChain4j to build native schema + * types without re-parsing) and a pre-built JSON Schema string (used directly by Spring AI and OpenAI). + * + * @since 4.22 + */ +public final class AiToolSpec { + + private final String name; + private final String description; + private final Map parameterDefs; + private final String parametersJsonSchema; + private final DefaultConsumer consumer; + + public AiToolSpec( + String name, + String description, + Map parameterDefs, + String parametersJsonSchema, + DefaultConsumer consumer) { + this.name = name; + this.description = description; + this.parameterDefs = parameterDefs != null ? Collections.unmodifiableMap(parameterDefs) : Map.of(); + this.parametersJsonSchema = parametersJsonSchema; + this.consumer = consumer; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + /** + * Structured parameter definitions. Used by LangChain4j to build native {@code JsonObjectSchema} without re-parsing + * the JSON Schema string. + */ + public Map getParameterDefs() { + return parameterDefs; + } + + /** + * Pre-built JSON Schema string for the tool parameters. Used directly by Spring AI ({@code inputSchema}) and OpenAI + * ({@code function.parameters}). + */ + public String getParametersJsonSchema() { + return parametersJsonSchema; + } + + /** + * The Camel consumer that executes this tool's route when the LLM invokes it. + */ + public DefaultConsumer getConsumer() { + return consumer; + } + + // Consumer is included so that two endpoints with the same tool name but different configurations + // (e.g. different descriptions or parameters) are treated as distinct specs in the registry sets. + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AiToolSpec that = (AiToolSpec) o; + return Objects.equals(name, that.name) + && Objects.equals(description, that.description) + && Objects.equals(parameterDefs, that.parameterDefs) + && Objects.equals(parametersJsonSchema, that.parametersJsonSchema) + && Objects.equals(consumer, that.consumer); + } + + @Override + public int hashCode() { + return Objects.hash(name, description, parameterDefs, parametersJsonSchema, consumer); + } + + @Override + public String toString() { + return "AiToolSpec{name=" + name + '}'; + } +} diff --git a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolEndpointLifecycleTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolEndpointLifecycleTest.java new file mode 100644 index 0000000000000..762a0349f856b --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolEndpointLifecycleTest.java @@ -0,0 +1,344 @@ +/* + * 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.Set; + +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.assertThatThrownBy; + +public class AiToolEndpointLifecycleTest extends CamelTestSupport { + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + public void configure() { + from("ai-tool:getWeather" + + "?tags=weather" + + "&description=Get the current weather for a city" + + "¶meter.city=string" + + "¶meter.city.description=The city name" + + "¶meter.city.required=true" + + "¶meter.unit=string" + + "¶meter.unit.enum=celsius,fahrenheit") + .setBody(simple("Sunny in ${header.city}")); + } + }; + } + + @Test + public void testToolRegisteredOnStart() { + AiToolRegistry registry = AiToolRegistry.getOrCreate(context); + + Set tools = registry.getTools().get("weather"); + assertThat(tools) + .as("Tools registered under 'weather' tag") + .isNotNull() + .hasSize(1); + + AiToolSpec spec = tools.iterator().next(); + assertThat(spec.getName()) + .as("Tool name") + .isEqualTo("getWeather"); + assertThat(spec.getDescription()) + .as("Tool description") + .isEqualTo("Get the current weather for a city"); + assertThat(spec.getConsumer()) + .as("Tool consumer") + .isNotNull(); + assertThat(spec.getParametersJsonSchema()) + .as("Tool JSON schema") + .isNotNull(); + assertThat(spec.getParameterDefs()) + .as("Tool parameter definitions") + .isNotNull() + .hasSize(2) + .containsKey("city") + .containsKey("unit"); + assertThat(spec.getParameterDefs().get("city").isRequired()) + .as("City parameter should be required") + .isTrue(); + } + + @Test + public void testToolDeregisteredOnStop() throws Exception { + AiToolRegistry registry = AiToolRegistry.getOrCreate(context); + + assertThat(registry.getTools().get("weather")) + .as("Tool should be registered before stop") + .isNotNull(); + + context.stop(); + + assertThat(registry.getTools().get("weather")) + .as("Tool should be deregistered after stop") + .isNull(); + } + + @Test + public void testProducerThrowsUnsupported() { + assertThatThrownBy(() -> context.getEndpoint("ai-tool:test?tags=t&description=d").createProducer()) + .as("Creating a producer should throw UnsupportedOperationException") + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + public void testToolNameUsedAsName() throws Exception { + context.addRoutes(new RouteBuilder() { + public void configure() { + from("ai-tool:getUserProfile" + + "?tags=test" + + "&description=Get user profile") + .setBody(constant("profile")); + } + }); + + Set tools = AiToolRegistry.getOrCreate(context).getTools().get("test"); + assertThat(tools) + .as("Tools registered under 'test' tag") + .isNotNull() + .hasSize(1) + .first() + .satisfies(spec -> assertThat(spec.getName()) + .as("Tool name should match toolName") + .isEqualTo("getUserProfile")); + } + + @Test + public void testTaglessToolRegisteredInDefaultPool() throws Exception { + context.addRoutes(new RouteBuilder() { + public void configure() { + from("ai-tool:defaultTool" + + "?description=A tool with no tags") + .setBody(constant("default")); + } + }); + + AiToolRegistry registry = AiToolRegistry.getOrCreate(context); + assertThat(registry.getDefaultTools()) + .as("Default pool should contain the tagless tool") + .hasSize(1) + .first() + .satisfies(spec -> assertThat(spec.getName()) + .as("Tool name") + .isEqualTo("defaultTool")); + } + + @Test + public void testTaglessToolDeregisteredOnStop() throws Exception { + context.addRoutes(new RouteBuilder() { + public void configure() { + from("ai-tool:tempTool" + + "?description=Temporary tool") + .setBody(constant("temp")); + } + }); + + AiToolRegistry registry = AiToolRegistry.getOrCreate(context); + assertThat(registry.getDefaultTools()) + .as("Default pool should contain the tool before stop") + .isNotEmpty(); + + context.stop(); + + assertThat(registry.getDefaultTools()) + .as("Default pool should be empty after stop") + .isEmpty(); + } + + @Test + public void testMissingDescriptionDefaultsToToolName() throws Exception { + context.addRoutes(new RouteBuilder() { + public void configure() { + from("ai-tool:noDesc?tags=test") + .setBody(constant("no description")); + } + }); + + AiToolRegistry registry = AiToolRegistry.getOrCreate(context); + Set tools = registry.getToolsByTag("test"); + assertThat(tools) + .as("Tool should be registered even without explicit description") + .hasSize(1) + .first() + .satisfies(spec -> assertThat(spec.getDescription()) + .as("Description should default to toolName") + .isEqualTo("noDesc")); + } + + @Test + public void testMultipleTagsRegistration() throws Exception { + context.addRoutes(new RouteBuilder() { + public void configure() { + from("ai-tool:sharedTool" + + "?tags=assistant,admin" + + "&description=Shared tool") + .setBody(constant("shared")); + } + }); + + AiToolRegistry registry = AiToolRegistry.getOrCreate(context); + assertThat(registry.getTools().get("assistant")) + .as("Tool should be registered under 'assistant' tag") + .isNotNull() + .hasSize(1); + assertThat(registry.getTools().get("admin")) + .as("Tool should be registered under 'admin' tag") + .isNotNull() + .hasSize(1); + } + + @Test + public void testMultipleTagsDeregistrationOnStop() throws Exception { + context.addRoutes(new RouteBuilder() { + public void configure() { + from("ai-tool:multiTagTool" + + "?tags=tagA,tagB" + + "&description=Multi-tag tool") + .setBody(constant("multi")); + } + }); + + AiToolRegistry registry = AiToolRegistry.getOrCreate(context); + assertThat(registry.getToolsByTag("tagA")).isNotEmpty(); + assertThat(registry.getToolsByTag("tagB")).isNotEmpty(); + + context.stop(); + + assertThat(registry.getTools().get("tagA")) + .as("Tool should be deregistered from tagA after stop") + .isNull(); + assertThat(registry.getTools().get("tagB")) + .as("Tool should be deregistered from tagB after stop") + .isNull(); + } + + @Test + public void testGetToolsByTagMergesDefaultPool() throws Exception { + context.addRoutes(new RouteBuilder() { + public void configure() { + from("ai-tool:taggedTool" + + "?tags=myTag" + + "&description=A tagged tool") + .setBody(constant("tagged")); + + from("ai-tool:defaultTool2" + + "?description=A default pool tool") + .setBody(constant("default")); + } + }); + + AiToolRegistry registry = AiToolRegistry.getOrCreate(context); + Set merged = registry.getToolsByTag("myTag"); + + assertThat(merged) + .as("getToolsByTag should return tagged + default pool tools") + .hasSize(2) + .extracting(AiToolSpec::getName) + .containsExactlyInAnyOrder("taggedTool", "defaultTool2"); + } + + @Test + public void testSuspendDeregistersAndResumeReregisters() throws Exception { + context.addRoutes(new RouteBuilder() { + public void configure() { + from("ai-tool:suspendable" + + "?tags=lifecycle" + + "&description=Tool for suspend test") + .routeId("suspendableRoute") + .setBody(constant("ok")); + } + }); + + AiToolRegistry registry = AiToolRegistry.getOrCreate(context); + assertThat(registry.getToolsByTag("lifecycle")) + .as("Tool should be registered before suspend") + .extracting(AiToolSpec::getName) + .contains("suspendable"); + + context.getRouteController().suspendRoute("suspendableRoute"); + + assertThat(registry.getTools().get("lifecycle")) + .as("Tool should be deregistered after suspend") + .isNull(); + + context.getRouteController().resumeRoute("suspendableRoute"); + + assertThat(registry.getToolsByTag("lifecycle")) + .as("Tool should be re-registered after resume") + .extracting(AiToolSpec::getName) + .contains("suspendable"); + } + + @Test + public void testSuspendDefaultPoolToolDeregistersAndResumeReregisters() throws Exception { + context.addRoutes(new RouteBuilder() { + public void configure() { + from("ai-tool:suspendableDefault" + + "?description=Default pool suspend test") + .routeId("suspendableDefaultRoute") + .setBody(constant("ok")); + } + }); + + AiToolRegistry registry = AiToolRegistry.getOrCreate(context); + assertThat(registry.getDefaultTools()) + .as("Tool should be in default pool before suspend") + .extracting(AiToolSpec::getName) + .contains("suspendableDefault"); + + context.getRouteController().suspendRoute("suspendableDefaultRoute"); + + assertThat(registry.getDefaultTools()) + .as("Default pool should not contain tool after suspend") + .extracting(AiToolSpec::getName) + .doesNotContain("suspendableDefault"); + + context.getRouteController().resumeRoute("suspendableDefaultRoute"); + + assertThat(registry.getDefaultTools()) + .as("Tool should be back in default pool after resume") + .extracting(AiToolSpec::getName) + .contains("suspendableDefault"); + } + + @Test + public void testGetAllToolsReturnsAllPools() throws Exception { + context.addRoutes(new RouteBuilder() { + public void configure() { + from("ai-tool:toolA?tags=alpha&description=Tool A") + .setBody(constant("a")); + from("ai-tool:toolB?tags=beta&description=Tool B") + .setBody(constant("b")); + from("ai-tool:toolC?description=Tool C") + .setBody(constant("c")); + } + }); + + AiToolRegistry registry = AiToolRegistry.getOrCreate(context); + Set all = registry.getAllTools(); + + assertThat(all) + .as("getAllTools should return tools from all tags and default pool") + .extracting(AiToolSpec::getName) + .contains("toolA", "toolB", "toolC"); + } +} diff --git a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolExecutorTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolExecutorTest.java new file mode 100644 index 0000000000000..230f53defdc76 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolExecutorTest.java @@ -0,0 +1,270 @@ +/* + * 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.HashMap; +import java.util.Map; + +import org.apache.camel.Exchange; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.support.DefaultConsumer; +import org.apache.camel.support.DefaultExchange; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AiToolExecutorTest extends CamelTestSupport { + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + public void configure() { + from("ai-tool:greetUser" + + "?tags=test" + + "&description=Greet a user by name" + + "¶meter.name=string" + + "¶meter.name.description=The user name" + + "¶meter.name.required=true" + + "¶meter.age=integer" + + "¶meter.age.description=The user age") + .setBody(simple("Hello ${header.name}, age ${header.age}")); + + from("ai-tool:noParams" + + "?tags=test" + + "&description=A tool with no parameters") + .setBody(constant("no-param result")); + + from("ai-tool:nullBodyTool" + + "?tags=test" + + "&description=A tool that returns null body") + .setBody(constant((Object) null)); + + from("ai-tool:failingTool" + + "?tags=test" + + "&description=A tool that always fails") + .throwException(new RuntimeException("Simulated failure")); + + from("ai-tool:exchangeExceptionTool" + + "?tags=test" + + "&description=A tool that sets exception on exchange") + .process(exchange -> exchange.setException( + new RuntimeException("Exchange-level failure"))); + } + }; + } + + @Test + public void testExecuteWithDeclaredArguments() { + AiToolSpec spec = findSpec("greetUser"); + Exchange exchange = new DefaultExchange(context); + + Map arguments = new HashMap<>(); + arguments.put("name", "Alice"); + arguments.put("age", 30); + + AiToolResult result = AiToolExecutor.execute(spec, arguments, exchange); + + assertThat(result).isInstanceOf(AiToolResult.Success.class); + assertThat(((AiToolResult.Success) result).value()) + .isEqualTo("Hello Alice, age 30"); + } + + @Test + public void testExecuteIgnoresUndeclaredArguments() { + AiToolSpec spec = findSpec("greetUser"); + Exchange exchange = new DefaultExchange(context); + + Map arguments = new HashMap<>(); + arguments.put("name", "Bob"); + arguments.put("age", 25); + arguments.put("extraParam", "should-be-ignored"); + + AiToolResult result = AiToolExecutor.execute(spec, arguments, exchange); + + assertThat(result).isInstanceOf(AiToolResult.Success.class); + assertThat(((AiToolResult.Success) result).value()) + .as("Undeclared arguments should not affect the result") + .isEqualTo("Hello Bob, age 25"); + assertThat(exchange.getMessage().getHeader("extraParam")) + .as("Undeclared argument must not be set as header") + .isNull(); + } + + @Test + public void testExecuteWithNullArguments() { + AiToolSpec spec = findSpec("noParams"); + Exchange exchange = new DefaultExchange(context); + + AiToolResult result = AiToolExecutor.execute(spec, null, exchange); + + assertThat(result).isInstanceOf(AiToolResult.Success.class); + assertThat(((AiToolResult.Success) result).value()) + .isEqualTo("no-param result"); + } + + @Test + public void testExecuteWithEmptyArguments() { + AiToolSpec spec = findSpec("noParams"); + Exchange exchange = new DefaultExchange(context); + + AiToolResult result = AiToolExecutor.execute(spec, Map.of(), exchange); + + assertThat(result).isInstanceOf(AiToolResult.Success.class); + assertThat(((AiToolResult.Success) result).value()) + .isEqualTo("no-param result"); + } + + @Test + public void testExecuteReturnsExecutionErrorOnRouteFailure() { + AiToolSpec spec = findSpec("failingTool"); + Exchange exchange = new DefaultExchange(context); + + AiToolResult result = AiToolExecutor.execute(spec, null, exchange); + + assertThat(result).isInstanceOf(AiToolResult.ExecutionError.class); + AiToolResult.ExecutionError error = (AiToolResult.ExecutionError) result; + assertThat(error.message()).contains("failingTool").contains("Simulated failure"); + assertThat(error.cause()).isInstanceOf(RuntimeException.class); + } + + @Test + public void testExecuteReturnsExecutionErrorOnExchangeException() { + AiToolSpec spec = findSpec("exchangeExceptionTool"); + Exchange exchange = new DefaultExchange(context); + + AiToolResult result = AiToolExecutor.execute(spec, null, exchange); + + assertThat(result).isInstanceOf(AiToolResult.ExecutionError.class); + AiToolResult.ExecutionError error = (AiToolResult.ExecutionError) result; + assertThat(error.message()).contains("exchangeExceptionTool").contains("Exchange-level failure"); + assertThat(error.cause()).isInstanceOf(RuntimeException.class); + } + + @Test + public void testExecuteReturnsArgumentErrorForMissingRequired() { + AiToolSpec spec = findSpec("greetUser"); + Exchange exchange = new DefaultExchange(context); + + Map arguments = new HashMap<>(); + arguments.put("age", 30); + + AiToolResult result = AiToolExecutor.execute(spec, arguments, exchange); + + assertThat(result).isInstanceOf(AiToolResult.ArgumentError.class); + AiToolResult.ArgumentError error = (AiToolResult.ArgumentError) result; + assertThat(error.message()).contains("Missing required argument 'name'"); + assertThat(error.cause()).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testExecuteReturnsExecutionErrorForNullConsumer() { + AiToolSpec spec = new AiToolSpec("ghostTool", "A tool with no consumer", Map.of(), null, null); + Exchange exchange = new DefaultExchange(context); + + AiToolResult result = AiToolExecutor.execute(spec, null, exchange); + + assertThat(result).isInstanceOf(AiToolResult.ExecutionError.class); + AiToolResult.ExecutionError error = (AiToolResult.ExecutionError) result; + assertThat(error.message()).contains("No consumer available for tool 'ghostTool'"); + assertThat(error.cause()).isInstanceOf(IllegalStateException.class); + } + + @Test + public void testExecuteReturnsExecutionErrorForNullProcessor() { + AiToolEndpoint endpoint = context.getEndpoint( + "ai-tool:nullProc?tags=test&description=test", AiToolEndpoint.class); + DefaultConsumer consumerWithNullProcessor = new DefaultConsumer(endpoint, null); + + AiToolSpec spec = new AiToolSpec( + "nullProc", "test", Map.of(), null, consumerWithNullProcessor); + Exchange exchange = new DefaultExchange(context); + + AiToolResult result = AiToolExecutor.execute(spec, null, exchange); + + assertThat(result).isInstanceOf(AiToolResult.ExecutionError.class); + AiToolResult.ExecutionError error = (AiToolResult.ExecutionError) result; + assertThat(error.message()).contains("No route processor available for tool 'nullProc'"); + assertThat(error.cause()).isInstanceOf(IllegalStateException.class); + } + + @Test + public void testExecuteReturnsNoResultForNullBody() { + AiToolSpec spec = findSpec("nullBodyTool"); + Exchange exchange = new DefaultExchange(context); + + AiToolResult result = AiToolExecutor.execute(spec, null, exchange); + + assertThat(result).isInstanceOf(AiToolResult.Success.class); + assertThat(((AiToolResult.Success) result).value()) + .as("Null body should produce 'No result' sentinel") + .isEqualTo("No result"); + } + + @Test + public void testArgumentsAccessibleViaHeaders() { + AiToolSpec spec = findSpec("greetUser"); + Exchange exchange = new DefaultExchange(context); + + Map arguments = new HashMap<>(); + arguments.put("name", "Diana"); + arguments.put("age", 28); + + AiToolExecutor.execute(spec, arguments, exchange); + + assertThat(exchange.getMessage().getHeader("name", String.class)) + .as("Argument should be set as exchange header") + .isEqualTo("Diana"); + assertThat(exchange.getMessage().getHeader("age", Integer.class)) + .as("Integer argument should be set as exchange header") + .isEqualTo(28); + } + + @Test + public void testCamelPrefixedArgumentsRejectedFromHeaders() { + AiToolSpec spec = findSpec("noParams"); + Exchange exchange = new DefaultExchange(context); + + Map arguments = new HashMap<>(); + arguments.put("CamelOverrideEndpoint", "http://evil.com"); + arguments.put("camelFoo", "bar"); + arguments.put("org.apache.camel.hack", "value"); + arguments.put("safeParam", "ok"); + + AiToolExecutor.execute(spec, arguments, exchange); + + assertThat(exchange.getMessage().getHeader("CamelOverrideEndpoint")) + .as("Camel-prefixed argument must not be set as header") + .isNull(); + assertThat(exchange.getMessage().getHeader("camelFoo")) + .as("camel-prefixed argument must not be set as header") + .isNull(); + assertThat(exchange.getMessage().getHeader("org.apache.camel.hack")) + .as("org.apache.camel. prefixed argument must not be set as header") + .isNull(); + assertThat(exchange.getMessage().getHeader("safeParam", String.class)) + .as("Non-Camel argument should be set as header") + .isEqualTo("ok"); + } + + private AiToolSpec findSpec(String toolName) { + return AiToolRegistry.getOrCreate(context).getToolsByTag("test").stream() + .filter(s -> toolName.equals(s.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("Tool not found: " + toolName)); + } +} diff --git a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolIntegrationTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolIntegrationTest.java new file mode 100644 index 0000000000000..c17f9e81b4b3c --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolIntegrationTest.java @@ -0,0 +1,313 @@ +/* + * 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.HashMap; +import java.util.Map; + +import org.apache.camel.Exchange; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.spi.Registry; +import org.apache.camel.support.DefaultExchange; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests that exercise realistic AI tool route scenarios end-to-end through the executor, using bean + * services to simulate real backends (database lookups, calculations, etc.). + */ +public class AiToolIntegrationTest extends CamelTestSupport { + + @Override + protected void bindToRegistry(Registry registry) throws Exception { + registry.bind("userService", new UserService()); + registry.bind("calculator", new CalculatorService()); + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + public void configure() { + from("ai-tool:lookupUser" + + "?tags=users" + + "&description=Look up a user by ID" + + "¶meter.id=integer" + + "¶meter.id.description=The user ID" + + "¶meter.id.required=true") + .to("bean:userService?method=findById(${header.id})"); + + from("ai-tool:calculate" + + "?tags=math" + + "&description=Perform a math operation" + + "¶meter.a=number" + + "¶meter.a.description=First operand" + + "¶meter.a.required=true" + + "¶meter.b=number" + + "¶meter.b.description=Second operand" + + "¶meter.b.required=true" + + "¶meter.operation=string" + + "¶meter.operation.description=The operation" + + "¶meter.operation.enum=add,subtract,multiply,divide" + + "¶meter.operation.required=true") + .to("bean:calculator?method=compute(${header.a}, ${header.b}, ${header.operation})"); + + from("ai-tool:searchUsers" + + "?tags=users" + + "&description=Search users by name prefix" + + "¶meter.prefix=string" + + "¶meter.prefix.description=Name prefix to search" + + "¶meter.prefix.required=true") + .to("bean:userService?method=searchByPrefix(${header.prefix})"); + + from("ai-tool:dynamicLookup" + + "?tags=users" + + "&description=Look up a user by ID using dynamic endpoint" + + "¶meter.id=integer" + + "¶meter.id.description=The user ID" + + "¶meter.id.required=true") + .toD("bean:userService?method=findById(${header.id})"); + } + }; + } + + @Test + public void testBeanLookupByHeaderId() { + AiToolSpec spec = findSpec("lookupUser"); + Exchange exchange = new DefaultExchange(context); + + Map arguments = new HashMap<>(); + arguments.put("id", 1); + + AiToolResult result = AiToolExecutor.execute(spec, arguments, exchange); + + assertThat(result) + .as("Bean lookup via header should succeed") + .isInstanceOf(AiToolResult.Success.class); + assertThat(((AiToolResult.Success) result).value()) + .as("Bean should resolve id from exchange header") + .isEqualTo("Alice (id=1)"); + } + + @Test + public void testBeanLookupWithUnknownId() { + AiToolSpec spec = findSpec("lookupUser"); + Exchange exchange = new DefaultExchange(context); + + Map arguments = new HashMap<>(); + arguments.put("id", 999); + + AiToolResult result = AiToolExecutor.execute(spec, arguments, exchange); + + assertThat(result).isInstanceOf(AiToolResult.Success.class); + assertThat(((AiToolResult.Success) result).value()) + .as("Unknown ID should return not-found message") + .isEqualTo("User not found"); + } + + @Test + public void testCalculatorAdd() { + AiToolSpec spec = findSpec("calculate"); + Exchange exchange = new DefaultExchange(context); + + Map arguments = new HashMap<>(); + arguments.put("a", 10.5); + arguments.put("b", 3.2); + arguments.put("operation", "add"); + + AiToolResult result = AiToolExecutor.execute(spec, arguments, exchange); + + assertThat(result).isInstanceOf(AiToolResult.Success.class); + assertThat(((AiToolResult.Success) result).value()) + .as("Bean should receive all three arguments from headers") + .isEqualTo("13.7"); + } + + @Test + public void testCalculatorDivide() { + AiToolSpec spec = findSpec("calculate"); + Exchange exchange = new DefaultExchange(context); + + Map arguments = new HashMap<>(); + arguments.put("a", 20.0); + arguments.put("b", 4.0); + arguments.put("operation", "divide"); + + AiToolResult result = AiToolExecutor.execute(spec, arguments, exchange); + + assertThat(result).isInstanceOf(AiToolResult.Success.class); + assertThat(((AiToolResult.Success) result).value()) + .isEqualTo("5.0"); + } + + @Test + public void testCalculatorDivideByZero() { + AiToolSpec spec = findSpec("calculate"); + Exchange exchange = new DefaultExchange(context); + + Map arguments = new HashMap<>(); + arguments.put("a", 10.0); + arguments.put("b", 0.0); + arguments.put("operation", "divide"); + + AiToolResult result = AiToolExecutor.execute(spec, arguments, exchange); + + assertThat(result).isInstanceOf(AiToolResult.ExecutionError.class); + AiToolResult.ExecutionError error = (AiToolResult.ExecutionError) result; + assertThat(error.message()).contains("Division by zero"); + assertThat(error.cause()).isInstanceOf(ArithmeticException.class); + } + + @Test + public void testCalculatorUnknownOperation() { + AiToolSpec spec = findSpec("calculate"); + Exchange exchange = new DefaultExchange(context); + + Map arguments = new HashMap<>(); + arguments.put("a", 10.0); + arguments.put("b", 5.0); + arguments.put("operation", "modulo"); + + AiToolResult result = AiToolExecutor.execute(spec, arguments, exchange); + + assertThat(result).isInstanceOf(AiToolResult.ExecutionError.class); + AiToolResult.ExecutionError error = (AiToolResult.ExecutionError) result; + assertThat(error.message()).contains("Unknown operation"); + assertThat(error.cause()).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testDynamicEndpointLookup() { + AiToolSpec spec = findSpec("dynamicLookup"); + Exchange exchange = new DefaultExchange(context); + + Map arguments = new HashMap<>(); + arguments.put("id", 2); + + AiToolResult result = AiToolExecutor.execute(spec, arguments, exchange); + + assertThat(result) + .as("toD should resolve header in endpoint URI") + .isInstanceOf(AiToolResult.Success.class); + assertThat(((AiToolResult.Success) result).value()) + .isEqualTo("Bob (id=2)"); + } + + @Test + public void testDynamicEndpointLookupUnknownId() { + AiToolSpec spec = findSpec("dynamicLookup"); + Exchange exchange = new DefaultExchange(context); + + Map arguments = new HashMap<>(); + arguments.put("id", 42); + + AiToolResult result = AiToolExecutor.execute(spec, arguments, exchange); + + assertThat(result).isInstanceOf(AiToolResult.Success.class); + assertThat(((AiToolResult.Success) result).value()) + .isEqualTo("User not found"); + } + + @Test + public void testSearchByPrefix() { + AiToolSpec spec = findSpec("searchUsers"); + Exchange exchange = new DefaultExchange(context); + + Map arguments = new HashMap<>(); + arguments.put("prefix", "A"); + + AiToolResult result = AiToolExecutor.execute(spec, arguments, exchange); + + assertThat(result).isInstanceOf(AiToolResult.Success.class); + assertThat(((AiToolResult.Success) result).value()) + .as("Should return all users matching prefix, sorted alphabetically") + .isEqualTo("Adam, Alice"); + } + + @Test + public void testSearchByPrefixNoMatch() { + AiToolSpec spec = findSpec("searchUsers"); + Exchange exchange = new DefaultExchange(context); + + Map arguments = new HashMap<>(); + arguments.put("prefix", "Z"); + + AiToolResult result = AiToolExecutor.execute(spec, arguments, exchange); + + assertThat(result).isInstanceOf(AiToolResult.Success.class); + assertThat(((AiToolResult.Success) result).value()) + .as("No matching users should return not-found message") + .isEqualTo("No users found"); + } + + private AiToolSpec findSpec(String toolName) { + return AiToolRegistry.getOrCreate(context).getAllTools().stream() + .filter(s -> toolName.equals(s.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("Tool not found: " + toolName)); + } + + public static class UserService { + + private static final Map USERS = Map.of( + 1, "Alice", + 2, "Bob", + 3, "Adam"); + + public String findById(int id) { + String name = USERS.get(id); + return name != null ? name + " (id=" + id + ")" : "User not found"; + } + + public String searchByPrefix(String prefix) { + String matches = USERS.values().stream() + .filter(name -> name.startsWith(prefix)) + .sorted() + .reduce((a, b) -> a + ", " + b) + .orElse("No users found"); + return matches; + } + } + + public static class CalculatorService { + + public String compute(double a, double b, String operation) { + double result; + switch (operation) { + case "add": + result = a + b; + break; + case "subtract": + result = a - b; + break; + case "multiply": + result = a * b; + break; + case "divide": + if (b == 0) { + throw new ArithmeticException("Division by zero"); + } + result = a / b; + break; + default: + throw new IllegalArgumentException("Unknown operation: " + operation); + } + return String.valueOf(result); + } + } +} diff --git a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolParameterHelperTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolParameterHelperTest.java new file mode 100644 index 0000000000000..09b1c9144b00d --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolParameterHelperTest.java @@ -0,0 +1,269 @@ +/* + * 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.LinkedHashMap; +import java.util.Map; + +import org.apache.camel.util.json.DeserializationException; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AiToolParameterHelperTest { + + @Test + public void testSplitTags() { + assertThat(AiToolParameterHelper.splitTags("a,b,c")) + .as("Splitting comma-separated tags") + .containsExactly("a", "b", "c"); + assertThat(AiToolParameterHelper.splitTags("single")) + .as("Single tag without comma") + .containsExactly("single"); + assertThat(AiToolParameterHelper.splitTags(" a , b , c ")) + .as("Whitespace-padded tags should be trimmed") + .containsExactly("a", "b", "c"); + } + + @Test + public void testSplitTagsNullAndEmpty() { + assertThat(AiToolParameterHelper.splitTags(null)) + .as("Null input should return empty array") + .isEmpty(); + assertThat(AiToolParameterHelper.splitTags("")) + .as("Empty string should return empty array") + .isEmpty(); + assertThat(AiToolParameterHelper.splitTags(" ")) + .as("Blank string should return empty array") + .isEmpty(); + } + + @Test + public void testParseSimpleType() { + Map params = Map.of("city", "string"); + + Map defs = AiToolParameterHelper.parseParameterMetadata(params); + + assertThat(defs) + .as("Should parse one parameter") + .hasSize(1); + assertThat(defs.get("city")) + .as("City parameter") + .satisfies(city -> { + assertThat(city.getType()).as("Type").isEqualTo("string"); + assertThat(city.getDescription()).as("Description").isNull(); + assertThat(city.isRequired()).as("Required").isFalse(); + }); + } + + @Test + public void testParseWithDescription() { + Map params = new LinkedHashMap<>(); + params.put("city", "string"); + params.put("city.description", "The city name"); + + Map defs = AiToolParameterHelper.parseParameterMetadata(params); + + assertThat(defs) + .as("Should parse one parameter with description") + .hasSize(1); + assertThat(defs.get("city")) + .as("City parameter") + .satisfies(city -> { + assertThat(city.getType()).as("Type").isEqualTo("string"); + assertThat(city.getDescription()).as("Description").isEqualTo("The city name"); + }); + } + + @Test + public void testParseWithRequired() { + Map params = new LinkedHashMap<>(); + params.put("city", "string"); + params.put("city.required", "true"); + + Map defs = AiToolParameterHelper.parseParameterMetadata(params); + + assertThat(defs.get("city").isRequired()) + .as("City parameter should be required") + .isTrue(); + } + + @Test + public void testParseWithEnum() { + Map params = new LinkedHashMap<>(); + params.put("unit", "string"); + params.put("unit.enum", "celsius,fahrenheit"); + + Map defs = AiToolParameterHelper.parseParameterMetadata(params); + + assertThat(defs.get("unit").getEnumValues()) + .as("Unit enum values") + .isNotNull() + .containsExactly("celsius", "fahrenheit"); + } + + @Test + public void testParseMultipleParameters() { + Map params = new LinkedHashMap<>(); + params.put("city", "string"); + params.put("city.description", "The city name"); + params.put("city.required", "true"); + params.put("unit", "string"); + params.put("unit.enum", "celsius,fahrenheit"); + params.put("days", "integer"); + + Map defs = AiToolParameterHelper.parseParameterMetadata(params); + + assertThat(defs) + .as("Should parse all three parameters") + .hasSize(3) + .containsKey("city") + .containsKey("unit") + .containsKey("days"); + assertThat(defs.get("city").getType()).as("City type").isEqualTo("string"); + assertThat(defs.get("days").getType()).as("Days type").isEqualTo("integer"); + } + + @Test + public void testBuildJsonSchemaBasic() throws DeserializationException { + Map params = new LinkedHashMap<>(); + params.put("city", "string"); + params.put("city.description", "The city name"); + params.put("city.required", "true"); + + String schema = AiToolParameterHelper.buildJsonSchema(params); + JsonObject root = (JsonObject) Jsoner.deserialize(schema); + + assertThat(root.getString("type")) + .as("Schema type") + .isEqualTo("object"); + JsonObject properties = root.getMap("properties"); + JsonObject city = (JsonObject) properties.get("city"); + assertThat(city) + .as("City property should exist") + .isNotNull(); + assertThat(city.getString("type")) + .as("City property type") + .isEqualTo("string"); + assertThat(city.getString("description")) + .as("City property description") + .isEqualTo("The city name"); + JsonArray required = root.getCollection("required"); + assertThat(required) + .as("Required array should contain city") + .contains("city"); + assertThat(root.getBoolean("additionalProperties")) + .as("additionalProperties should be false") + .isFalse(); + } + + @Test + public void testBuildJsonSchemaWithEnum() throws DeserializationException { + Map params = new LinkedHashMap<>(); + params.put("unit", "string"); + params.put("unit.enum", "celsius,fahrenheit"); + + String schema = AiToolParameterHelper.buildJsonSchema(params); + JsonObject root = (JsonObject) Jsoner.deserialize(schema); + + JsonObject unitProp = (JsonObject) root.getMap("properties").get("unit"); + JsonArray enumValues = (JsonArray) unitProp.get("enum"); + assertThat(enumValues) + .as("Unit enum should be present") + .isNotNull() + .hasSize(2); + assertThat(enumValues.get(0)) + .as("First enum value") + .isEqualTo("celsius"); + assertThat(enumValues.get(1)) + .as("Second enum value") + .isEqualTo("fahrenheit"); + } + + @Test + public void testBuildJsonSchemaTypeMapping() throws DeserializationException { + Map params = new LinkedHashMap<>(); + params.put("count", "integer"); + params.put("score", "number"); + params.put("active", "boolean"); + params.put("name", "string"); + + String schema = AiToolParameterHelper.buildJsonSchema(params); + JsonObject root = (JsonObject) Jsoner.deserialize(schema); + JsonObject properties = root.getMap("properties"); + + assertThat(((JsonObject) properties.get("count")).getString("type")) + .as("Integer type mapping").isEqualTo("integer"); + assertThat(((JsonObject) properties.get("score")).getString("type")) + .as("Number type mapping").isEqualTo("number"); + assertThat(((JsonObject) properties.get("active")).getString("type")) + .as("Boolean type mapping").isEqualTo("boolean"); + assertThat(((JsonObject) properties.get("name")).getString("type")) + .as("String type mapping").isEqualTo("string"); + } + + @Test + public void testBuildJsonSchemaNoRequired() throws DeserializationException { + Map params = Map.of("city", "string"); + + String schema = AiToolParameterHelper.buildJsonSchema(params); + JsonObject root = (JsonObject) Jsoner.deserialize(schema); + + assertThat(root.get("required")) + .as("Schema should not have a required array when no parameter is required") + .isNull(); + } + + @Test + public void testBuildJsonSchemaTypeMappingAliases() throws DeserializationException { + Map params = new LinkedHashMap<>(); + params.put("a", "int"); + params.put("b", "long"); + params.put("c", "double"); + params.put("d", "float"); + params.put("e", "bool"); + + String schema = AiToolParameterHelper.buildJsonSchema(params); + JsonObject root = (JsonObject) Jsoner.deserialize(schema); + JsonObject properties = root.getMap("properties"); + + assertThat(((JsonObject) properties.get("a")).getString("type")) + .as("int alias").isEqualTo("integer"); + assertThat(((JsonObject) properties.get("b")).getString("type")) + .as("long alias").isEqualTo("integer"); + assertThat(((JsonObject) properties.get("c")).getString("type")) + .as("double alias").isEqualTo("number"); + assertThat(((JsonObject) properties.get("d")).getString("type")) + .as("float alias").isEqualTo("number"); + assertThat(((JsonObject) properties.get("e")).getString("type")) + .as("bool alias").isEqualTo("boolean"); + } + + @Test + public void testParseDefaultType() { + Map params = Map.of("city.description", "The city name"); + + Map defs = AiToolParameterHelper.parseParameterMetadata(params); + + assertThat(defs.get("city").getType()) + .as("Default type should be string when only description is provided") + .isEqualTo("string"); + } +} diff --git a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryTest.java new file mode 100644 index 0000000000000..e6993e82d5f23 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryTest.java @@ -0,0 +1,294 @@ +/* + * 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.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class AiToolRegistryTest { + + private AiToolRegistry registry; + + @BeforeEach + public void setUp() { + registry = new AiToolRegistry(); + } + + @Test + public void testPutAndGetTool() { + AiToolSpec spec = new AiToolSpec("getTool", "A test tool", Map.of(), null, null); + + registry.put("weather", spec); + + assertThat(registry.getTools().get("weather")) + .as("Tools registered under 'weather' tag") + .isNotNull() + .hasSize(1) + .contains(spec); + } + + @Test + public void testRemoveTool() { + AiToolSpec spec = new AiToolSpec("getTool", "A test tool", Map.of(), null, null); + + registry.put("weather", spec); + assertThat(registry.getTools().get("weather")) + .as("Tool should be registered before removal") + .hasSize(1); + + registry.remove("weather", spec); + assertThat(registry.getTools().get("weather")) + .as("Tag entry should be removed when last tool is removed") + .isNull(); + } + + @Test + public void testMultipleToolsWithSameTag() { + AiToolSpec spec1 = new AiToolSpec("tool1", "Tool 1", Map.of(), null, null); + AiToolSpec spec2 = new AiToolSpec("tool2", "Tool 2", Map.of(), null, null); + + registry.put("assistant", spec1); + registry.put("assistant", spec2); + + assertThat(registry.getTools().get("assistant")) + .as("Both tools should be registered under 'assistant' tag") + .hasSize(2); + } + + @Test + public void testRemoveOneOfMultipleTools() { + AiToolSpec spec1 = new AiToolSpec("tool1", "Tool 1", Map.of(), null, null); + AiToolSpec spec2 = new AiToolSpec("tool2", "Tool 2", Map.of(), null, null); + + registry.put("assistant", spec1); + registry.put("assistant", spec2); + + registry.remove("assistant", spec1); + + assertThat(registry.getTools().get("assistant")) + .as("Only the non-removed tool should remain") + .hasSize(1) + .contains(spec2) + .doesNotContain(spec1); + } + + @Test + public void testTagIsolation() { + AiToolSpec weatherSpec = new AiToolSpec("weather", "Weather", Map.of(), null, null); + AiToolSpec emailSpec = new AiToolSpec("email", "Email", Map.of(), null, null); + + registry.put("weather", weatherSpec); + registry.put("email", emailSpec); + + assertThat(registry.getTools().get("weather")) + .as("Weather tag should only contain the weather tool") + .hasSize(1) + .contains(weatherSpec); + + assertThat(registry.getTools().get("email")) + .as("Email tag should only contain the email tool") + .hasSize(1) + .contains(emailSpec); + } + + @Test + public void testRemoveFromNonExistentTag() { + AiToolSpec spec = new AiToolSpec("tool", "A tool", Map.of(), null, null); + registry.remove("nonexistent", spec); + + assertThat(registry.getTools()) + .as("Registry should remain empty after removing from non-existent tag") + .isEmpty(); + assertThat(registry.getAllTools()) + .as("All tools should remain empty") + .isEmpty(); + } + + @Test + public void testDefaultPoolPutAndRemove() { + AiToolSpec spec = new AiToolSpec("defaultTool", "Default", Map.of(), null, null); + + registry.putDefault(spec); + assertThat(registry.getDefaultTools()) + .as("Default pool should contain the tool") + .hasSize(1) + .contains(spec); + + registry.removeDefault(spec); + assertThat(registry.getDefaultTools()) + .as("Default pool should be empty after removal") + .isEmpty(); + } + + @Test + public void testGetToolsByTagIncludesDefaultPool() { + AiToolSpec taggedTool = new AiToolSpec("tagged", "Tagged", Map.of(), null, null); + AiToolSpec defaultTool = new AiToolSpec("default", "Default", Map.of(), null, null); + + registry.put("weather", taggedTool); + registry.putDefault(defaultTool); + + assertThat(registry.getToolsByTag("weather")) + .as("Tools by tag should include both tagged and default pool tools") + .hasSize(2) + .contains(taggedTool) + .contains(defaultTool); + } + + @Test + public void testGetToolsByTagWithNoMatchReturnsDefaultOnly() { + AiToolSpec defaultTool = new AiToolSpec("default", "Default", Map.of(), null, null); + registry.putDefault(defaultTool); + + assertThat(registry.getToolsByTag("nonexistent")) + .as("Non-existent tag should return only default pool tools") + .hasSize(1) + .contains(defaultTool); + } + + @Test + public void testConcurrentPutRemoveAndGet() throws Exception { + int threadCount = 10; + int opsPerThread = 100; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + + List> futures = new ArrayList<>(); + for (int t = 0; t < threadCount; t++) { + final int threadId = t; + futures.add(executor.submit(() -> { + try { + startLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + for (int i = 0; i < opsPerThread; i++) { + AiToolSpec spec = new AiToolSpec( + "tool-" + threadId + "-" + i, "desc", Map.of(), null, null); + registry.put("concurrent", spec); + registry.getToolsByTag("concurrent"); + registry.getAllTools(); + registry.remove("concurrent", spec); + } + })); + } + + startLatch.countDown(); + + for (Future f : futures) { + f.get(30, TimeUnit.SECONDS); + } + executor.shutdown(); + + assertThat(registry.getToolsByTag("concurrent")) + .as("All tools should be removed after concurrent ops") + .isEmpty(); + } + + @Test + public void testDuplicateToolNameUnderSameTagThrows() { + AiToolSpec spec1 = new AiToolSpec("sameName", "First", Map.of(), null, null); + AiToolSpec spec2 = new AiToolSpec("sameName", "Second", Map.of(), null, null); + + registry.put("weather", spec1); + + assertThatThrownBy(() -> registry.put("weather", spec2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Duplicate tool name 'sameName'") + .hasMessageContaining("tag 'weather'"); + + assertThat(registry.getTools().get("weather")) + .as("Only the first tool should be registered") + .hasSize(1) + .contains(spec1); + } + + @Test + public void testDuplicateToolNameInDefaultPoolThrows() { + AiToolSpec spec1 = new AiToolSpec("sameName", "First", Map.of(), null, null); + AiToolSpec spec2 = new AiToolSpec("sameName", "Second", Map.of(), null, null); + + registry.putDefault(spec1); + + assertThatThrownBy(() -> registry.putDefault(spec2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Duplicate tool name 'sameName'") + .hasMessageContaining("default pool"); + + assertThat(registry.getDefaultTools()) + .as("Only the first tool should be in the default pool") + .hasSize(1) + .contains(spec1); + } + + @Test + public void testSameToolNameDifferentTagsIsAllowed() { + AiToolSpec spec1 = new AiToolSpec("sameName", "Weather version", Map.of(), null, null); + AiToolSpec spec2 = new AiToolSpec("sameName", "Email version", Map.of(), null, null); + + registry.put("weather", spec1); + registry.put("email", spec2); + + assertThat(registry.getTools().get("weather")).hasSize(1).contains(spec1); + assertThat(registry.getTools().get("email")).hasSize(1).contains(spec2); + } + + @Test + public void testReRegisterAfterRemoveIsAllowed() { + AiToolSpec spec1 = new AiToolSpec("tool", "First", Map.of(), null, null); + AiToolSpec spec2 = new AiToolSpec("tool", "Second", Map.of(), null, null); + + registry.put("weather", spec1); + registry.remove("weather", spec1); + registry.put("weather", spec2); + + assertThat(registry.getTools().get("weather")) + .hasSize(1) + .contains(spec2); + } + + @Test + public void testGetAllToolsMergesTaggedAndDefault() { + AiToolSpec tool1 = new AiToolSpec("tool1", "Tool 1", Map.of(), null, null); + AiToolSpec tool2 = new AiToolSpec("tool2", "Tool 2", Map.of(), null, null); + AiToolSpec defaultTool = new AiToolSpec("default", "Default", Map.of(), null, null); + + registry.put("weather", tool1); + registry.put("email", tool2); + registry.putDefault(defaultTool); + + Set all = registry.getAllTools(); + assertThat(all) + .as("All tools should include tagged and default tools") + .hasSize(3) + .contains(tool1, tool2, defaultTool); + } +} diff --git a/components/camel-ai/camel-ai-tool/src/test/resources/log4j2.properties b/components/camel-ai/camel-ai-tool/src/test/resources/log4j2.properties new file mode 100644 index 0000000000000..c16098718141b --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/test/resources/log4j2.properties @@ -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. +## --------------------------------------------------------------------------- + +appender.file.type = File +appender.file.name = file +appender.file.fileName = target/camel-ai-tool-test.log +appender.file.layout.type = PatternLayout +appender.file.layout.pattern = %d [%-15.15t] %-5p %-30.30c{1} - %m%n + +appender.out.type = Console +appender.out.name = out +appender.out.layout.type = PatternLayout +appender.out.layout.pattern = [%30.30t] %-30.30c{1} %-5p %m%n + +rootLogger.level = INFO +rootLogger.appenderRef.file.ref = file diff --git a/components/camel-ai/pom.xml b/components/camel-ai/pom.xml index 4ab86cab3890e..05aa67814618d 100644 --- a/components/camel-ai/pom.xml +++ b/components/camel-ai/pom.xml @@ -35,6 +35,7 @@ camel-a2a + camel-ai-tool camel-chatscript camel-djl camel-docling diff --git a/core/camel-main/src/generated/resources/org/apache/camel/main/components.properties b/core/camel-main/src/generated/resources/org/apache/camel/main/components.properties index f81bc9f23152c..91be0f1a521e0 100644 --- a/core/camel-main/src/generated/resources/org/apache/camel/main/components.properties +++ b/core/camel-main/src/generated/resources/org/apache/camel/main/components.properties @@ -1,6 +1,7 @@ a2a activemq activemq6 +ai-tool amqp arangodb as2 diff --git a/design/aiTool.adoc b/design/aiTool.adoc new file mode 100644 index 0000000000000..782023cbbbe5b --- /dev/null +++ b/design/aiTool.adoc @@ -0,0 +1,545 @@ +--- +title: Unified AI tool abstraction +authors: + - "@Croway" + - "@zbendhiba" +reviewers: [] +approvers: [] +creation-date: 2026-07-06 +last-updated: 2026-07-06 +status: implementable +see-also: + - "https://issues.apache.org/jira/browse/CAMEL-23382" +replaces: [] +superseded-by: [] +--- + +== Summary + +Camel's AI components (`camel-langchain4j-tools`, `camel-spring-ai-tools`, +`camel-langchain4j-agent`) each implement their own mechanism for exposing Camel routes as +LLM-callable tools, while `camel-openai` lacks this capability entirely. This document +proposes a shared tool abstraction (`camel-ai-tool`) that all AI components discover +tools from. + +== Motivation + +As users of `camel-langchain4j`, `camel-spring-ai`, or `camel-openai`, we expect the +same behavior and workflow when implementing tools. Today that is not the case: + +* **`camel-langchain4j-tools`**, **`camel-spring-ai-tools`**, and **`camel-langchain4j-agent`** + each independently implement the same pattern (consumer endpoint, registry, spec classes) + for exposing Camel routes as LLM-callable tools, with duplicated code tightly coupled to + their respective framework types. +* **`camel-openai`** does not support Camel routes as tools. + +A tool defined for one framework cannot be used by another without re-registering it, +and bug fixes must be applied in multiple places. + +== Goals + +* Provide a *single* consumer endpoint (`ai-tool:toolName`) for defining Camel routes as + LLM-callable tools. No AI framework dependency required. +* Provide a CamelContext-scoped `AiToolRegistry` that replaces the duplicated + `CamelToolExecutorCache` classes in `camel-langchain4j-tools` and `camel-spring-ai-tools`. + Each `CamelContext` gets its own registry instance via the context plugin mechanism, + preventing tool leaks across contexts running in the same JVM. +* Provide a framework-agnostic `AiToolSpec` holding tool name, description, structured + parameter definitions, JSON Schema, and a reference to the Camel consumer that + implements the tool. +* Provide `AiToolExecutor`, a shared utility that resolves the route processor, validates + arguments against declared parameters, sets them as individual exchange headers (filtering + out `Camel*` / `org.apache.camel.*` prefixed names to prevent header-namespace injection), + invokes the route, and returns the result. Framework adapters only need to parse their + native argument format into a `Map`. +* Allow each AI component to discover tools from the shared registry via a `tags` parameter + and convert them to its native format (`ToolSpecification` for LangChain4j, + `ToolCallback` for Spring AI, `ChatCompletionFunctionTool` for OpenAI). + +== Context + +=== Current architecture + +Each AI module independently implements the same three-layer pattern: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ camel-langchain4j-tools │ +│ ┌──────────────────┐ ┌────────────────────┐ ┌────────────┐ │ +│ │ L4j Tools │ │ CamelToolExecutor │ │ L4j Tools │ │ +│ │ Consumer │→ │ Cache (singleton) │← │ Producer │ │ +│ │ registers tool │ │ tags → specs │ │ dispatches │ │ +│ └──────────────────┘ └────────────────────┘ └────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ camel-spring-ai-tools │ +│ ┌──────────────────┐ ┌────────────────────┐ │ +│ │ Spring AI Tools │ │ CamelToolExecutor │ (no producer — │ +│ │ Consumer │→ │ Cache (singleton) │ invoked via │ +│ │ registers tool │ │ tags → specs │ spring-ai-chat) │ +│ └──────────────────┘ └────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ camel-langchain4j-agent │ +│ (uses the LangChain4j CamelToolExecutorCache for Camel route │ +│ tools — tightly coupled to LangChain4j types) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +The caches are independent singletons with independent type hierarchies. A tool +registered in the LangChain4j cache is invisible to Spring AI and vice versa. + +==== Duplicated classes + +[cols="1,1,1"] +|=== +| Concept | camel-langchain4j-tools | camel-spring-ai-tools + +| Singleton registry +| `CamelToolExecutorCache` +| `CamelToolExecutorCache` + +| Tool specification +| `CamelToolSpecification` (wraps `ToolSpecification`) +| `CamelToolSpecification` (wraps `ToolCallback`) + +| Tag splitting +| `TagsHelper` +| `TagsHelper` + +| Consumer endpoint +| `LangChain4jToolsEndpoint` + `LangChain4jToolsConsumer` +| `SpringAiToolsEndpoint` + `SpringAiToolsConsumer` +|=== + +=== Design principles + +* **Define once, use everywhere.** A tool defined via `ai-tool:` is available to any AI + producer that queries the shared registry. +* **Framework adapters are thin.** Each AI component's bridge to `camel-ai-tool` should + be a small adapter that converts `AiToolSpec` → native type. The bulk of tool lifecycle, + parameter validation, and route dispatch lives in `camel-ai-tool`. +* **Tag-based discovery.** Tools are grouped by tags. Producers filter the registry by + tags to select which tools to expose to the LLM. Tools with no tags go into a default + pool available to all producers. +* **Camel route tools are the focus.** All AI components discover Camel route tools + from the shared registry. This abstraction also lays the groundwork for exposing + Camel route tools as MCP tools in the future. + +== Proposal + +=== Target architecture + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ camel-ai-tool │ +│ ┌──────────────┐ ┌───────────────┐ ┌───────────────┐ │ +│ │ AiToolSpec │ │ AiToolRegistry│ │ AiToolExecutor│ │ +│ │ (name, desc,│ │ (per-context) │ │ (validate → │ │ +│ │ params, │ │ tags → specs │ │ headers → │ │ +│ │ consumer) │ │ + default │ │ route → │ │ +│ └──────────────┘ │ pool │ │ result) │ │ +│ └───────────────┘ └───────────────┘ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ ai-tool:toolName consumer │ │ +│ │ registers route as tool on start, deregisters │ │ +│ │ on stop; validates description + parameters │ │ +│ └──────────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────────────────┘ + ▲ ▲ ▲ + │ │ │ + ┌────┴────┐ ┌─────┴───┐ ┌─────┴───┐ + │langchain│ │spring-ai│ │ openai │ + │ agent │ │ chat │ │ chat │ + │producer │ │producer │ │producer │ + └─────────┘ └─────────┘ └─────────┘ + Converts Converts Converts + AiToolSpec → AiToolSpec → AiToolSpec → + ToolSpecification ToolCallback ChatCompletion + + ToolExecutor FunctionTool +``` + +=== Module: `camel-ai-tool` + +Located at `components/camel-ai/camel-ai-tool` (Java package: +`org.apache.camel.component.ai.tool`). Consumer-only component with no AI +framework dependencies. Only `camel-support` (which transitively provides +`camel-util-json` for JSON Schema generation). + +==== `AiToolSpec` + +Framework-agnostic tool descriptor. Immutable after construction. + +[cols="1,2"] +|=== +| Field | Description + +| `name` +| Unique tool identifier (the `toolName` path parameter) + +| `description` +| Human-readable description passed verbatim to the LLM + +| `parameterDefs` +| Structured `Map`. Used by LangChain4j to build native + `JsonObjectSchema` without re-parsing JSON + +| `parametersJsonSchema` +| Pre-built JSON Schema string. Used directly by Spring AI (`inputSchema`) and + OpenAI (`function.parameters`) + +| `consumer` +| Reference to the `DefaultConsumer` that executes this tool's route +|=== + +==== `AiToolRegistry` + +CamelContext-scoped registry mapping tags to sets of `AiToolSpec` instances. Each +`CamelContext` gets its own instance, registered as a context plugin via +`context.getCamelContextExtension().addContextPlugin()`. Use the static factory +`AiToolRegistry.getOrCreate(context)` to obtain the instance for a given context. +Thread-safe via `ReentrantLock` (virtual-thread compatible). + +Two pools: + +* **Tagged tools**: `Map>`. Producers query by tag. +* **Default pool**: `Set`. Tools with no tags. Merged into every tag query + result, so they are available to all producers. + +Key methods: + +* `put(tag, spec)` / `remove(tag, spec)`: manage tagged tools +* `putDefault(spec)` / `removeDefault(spec)`: manage default pool +* `getToolsByTag(tag)`: returns tagged + default tools +* `getAllTools()`: returns all tools across all tags and default pool + +==== `AiToolExecutor` + +Static utility class that handles the common dispatch logic: + +1. Resolve the route `Processor` from the spec's consumer +2. Validate arguments against declared parameters (filter out undeclared, check required) +3. Set each argument as an individual exchange header (filtering out names starting with + `camel` or `org.apache.camel.` case-insensitively to prevent header-namespace injection, + following the same pattern as the A2A component) +4. Invoke the route processor +5. Return the result body as a string + +Framework adapters call `AiToolExecutor.execute(spec, arguments, exchange)` after +parsing their native argument format (JSON string, Map, etc.) into a +`Map`. They do not need to know how routes are resolved or invoked. + +IMPORTANT: The executor does *not* own the exchange lifecycle. The calling adapter is +responsible for creating the exchange (via `consumer.createExchange()`) before calling +`execute()`, and for releasing it (via `consumer.releaseExchange()`) afterwards, using +a try-finally block to prevent exchange leaks on error. + +==== Tool error handling + +`AiToolExecutor` classifies tool execution outcomes into three categories and returns +a typed result object. It does not decide error handling policy — that is the +framework adapter's responsibility. + +===== `AiToolResult` + +```java +public sealed interface AiToolResult { + record Success(String value) implements AiToolResult {} + record ArgumentError(String message, Exception cause) implements AiToolResult {} + record ExecutionError(String message, Exception cause) implements AiToolResult {} +} +``` + +`AiToolExecutor.execute()` returns `AiToolResult` instead of `String`: + +* **`Success`**: route executed normally, body returned as string. +* **`ArgumentError`**: the tool call failed before reaching the route. Missing required + argument or type mismatch. The LLM sent bad input. (Undeclared arguments are filtered + out with a warning, since LLMs frequently hallucinate extra parameters.) +* **`ExecutionError`**: the route processor threw an exception or the exchange carries + an exception after processing. + +===== Why a result object + +Each AI framework (LangChain4j, Spring AI, OpenAI) has its own error handling hooks. +Some want to propagate exceptions so their handlers can fire. Others want a string +returned to the LLM so it can self-correct. + +If `AiToolExecutor` catches everything and returns a string, no framework-level handler +can ever fire. If it always throws, frameworks that expect a string result break. + +By returning a typed result, `AiToolExecutor` stays framework-agnostic. It classifies +the error but does not choose the policy. Each adapter decides: + +* Return the error message as a string to the LLM (current behavior) +* Rethrow the cause so the framework's own error handler fires +* Log, wrap, or sanitize the error before returning + +===== Security consideration + +Returning raw exception messages to the LLM is a security concern. Exception messages +can contain stack traces, file paths, credentials, or PII. Once sent to the LLM, this +data can flow into responses, chat history, and provider logs. The `AiToolResult` +carries both `message` and `cause`, so adapters can choose to return a sanitized +message while logging the full cause internally. + +==== `AiToolParameterHelper` + +Shared utilities for parsing flat parameter maps and building JSON Schema: + +* `parseParameterMetadata(Map)`: parses URI-style parameters + (e.g., `city=string`, `city.description=The city name`, `city.required=true`, + `unit.enum=celsius,fahrenheit`) into structured `ParameterDef` objects. +* `buildJsonSchema(Map)`: generates a JSON Schema object string + from the same flat parameter map. Type mappings: `string`, `integer` (+ `int`, + `long`), `number` (+ `double`, `float`), `boolean` (+ `bool`). +* `splitTags(String)`: splits comma-separated tag list with whitespace trimming. + +==== `ai-tool:` consumer endpoint + +URI syntax: `ai-tool:toolName[?options]` + +[cols="1,1,1,3"] +|=== +| Option | Type | Required | Description + +| `toolName` +| String (path) +| Yes +| The tool name the LLM sees and uses to invoke the tool + +| `tags` +| String +| No +| Comma-separated tags. When omitted, tool goes into default pool. + +| `description` +| String +| No +| Human-readable description for the LLM. When omitted, defaults to the tool name. + +| `parameter.` +| String +| No +| Parameter type (`string`, `integer`, `number`, `boolean`) + +| `parameter..description` +| String +| No +| Parameter description for the LLM + +| `parameter..required` +| boolean +| No +| Whether the parameter is required (default: false) + +| `parameter..enum` +| String +| No +| Comma-separated list of allowed values +|=== + +Lifecycle (managed by `AiToolConsumer`): + +* **`doStart()`**: builds `AiToolSpec` from configuration, registers in `AiToolRegistry` +* **`doStop()`**: deregisters from `AiToolRegistry`, clears state +* **`doSuspend()`**: deregisters from `AiToolRegistry` (keeps state for resume) +* **`doResume()`**: re-registers in `AiToolRegistry` using saved state + +=== Adapter pattern for AI components + +Each AI component that wants to discover Camel route tools adds a dependency on +`camel-ai-tool` and implements a thin adapter: + +```java +// In the producer, discover tools from the context-scoped registry +AiToolRegistry registry = AiToolRegistry.getOrCreate(exchange.getContext()); +Set tools = registry.getToolsByTag(tagsOption); + +// Convert each AiToolSpec to the native tool format +for (AiToolSpec spec : tools) { + NativeToolType nativeTool = convertToNative(spec); + // add to LLM request +} + +// On tool call response, dispatch via AiToolExecutor +// The adapter owns the exchange lifecycle (create + release) +Exchange toolExchange = spec.getConsumer().createExchange(false); +try { + AiToolResult result = AiToolExecutor.execute(spec, parsedArguments, toolExchange); + // handle result +} finally { + spec.getConsumer().releaseExchange(toolExchange, false); +} +``` + +==== LangChain4j adapter + +Converts `AiToolSpec` → `dev.langchain4j.agent.tool.ToolSpecification`: + +* `spec.getName()` → `ToolSpecification.name()` +* `spec.getDescription()` → `ToolSpecification.description()` +* `spec.getParameterDefs()` → `JsonObjectSchema` properties (using structured defs + to avoid re-parsing JSON) + +==== Spring AI adapter + +Converts `AiToolSpec` → `org.springframework.ai.tool.ToolCallback`: + +* `spec.getName()` → callback name +* `spec.getDescription()` → callback description +* `spec.getParametersJsonSchema()` → `inputSchema` (used directly, no conversion) +* Tool function: `args → AiToolExecutor.execute(spec, args, exchange)` + +==== OpenAI adapter + +Converts `AiToolSpec` → `ChatCompletionFunctionTool`: + +* `spec.getName()` → `FunctionDefinition.name()` +* `spec.getDescription()` → `FunctionDefinition.description()` +* `spec.getParametersJsonSchema()` → `FunctionDefinition.parameters()` (used directly) + +This adds Camel route tool support to `camel-openai`. + +=== User-facing example + +Define a tool once, use it from any AI framework: + +```yaml +# Define tools (framework-agnostic) +- route: + from: + uri: ai-tool:getFacts + parameters: + tags: research + description: "Get facts about a given topic" + parameter.topic: string + parameter.topic.description: "The topic to research" + parameter.topic.required: true + steps: + - to: "direct:factsLookup" + +- route: + from: + uri: ai-tool:getWeather + parameters: + tags: research,utility + description: "Get current weather for a city" + parameter.city: string + parameter.city.description: "The city name" + parameter.city.required: true + parameter.unit: string + parameter.unit.enum: "celsius,fahrenheit" + steps: + - process: + ref: weatherProcessor + +# Use from LangChain4j agent +- route: + from: + uri: direct:langchain4j-chat + steps: + - to: "langchain4j-agent:assistant?agent=#myAgent&tags=research" + +# Use from Spring AI +- route: + from: + uri: direct:spring-ai-chat + steps: + - to: "spring-ai-chat:assistant?chatModel=#chatModel&tags=research" + +# Use from OpenAI +- route: + from: + uri: direct:openai-chat + steps: + - to: "openai:chat-completion?autoToolExecution=true&tags=research" +``` + +The tool definitions are identical regardless of which AI framework invokes them. The +`tags=research` parameter on each producer selects the matching tools from the shared +registry. + +=== Migration path + +The transition is incremental and non-breaking: + +[cols="1,2,2"] +|=== +| Phase | What changes | User impact + +| Existing consumers still work +| `langchain4j-tools:` and `spring-ai-tools:` consumer endpoints continue to register + in their respective `CamelToolExecutorCache` instances +| None. Existing routes keep working + +| Producers read from shared registry +| `langchain4j-agent`, `spring-ai-chat`, and `openai` producers read from + `AiToolRegistry` exclusively, replacing their old cache lookups +| Tools defined via `ai-tool:` become visible to all producers + +| Deprecate old consumers +| `langchain4j-tools:` consumer side deprecated in favor of `ai-tool:`. `spring-ai-tools:` + removed entirely (it was consumer-only) +| Users update `from("langchain4j-tools:...")` → `from("ai-tool:...")` + +| Remove old caches +| `CamelToolExecutorCache` removed from both modules +| No user impact (internal) +|=== + +== Development + +This work is split into sequential PRs to keep reviews focused: + +=== Step 1: Create `camel-ai-tool` module (2026-07-06) + +Create the new module with all core abstractions: + +* `AiToolSpec`: framework-agnostic tool descriptor +* `AiToolRegistry`: CamelContext-scoped registry (via context plugin) +* `AiToolConsumer`: consumer that registers/deregisters tools on start/stop +* `AiToolEndpoint`: consumer-only endpoint with parameter validation +* `AiToolExecutor`: shared dispatch utility +* `AiToolParameterHelper`: parameter parsing and JSON Schema generation +* Full unit test coverage + +No changes to existing modules. + +=== Step 2: Wire `camel-langchain4j-agent` producer + +Update `LangChain4jAgentProducer` to discover tools from `AiToolRegistry` instead of +(or in addition to) `CamelToolExecutorCache`. Add `tags` parameter support for filtering. +Adapter converts `AiToolSpec` → `ToolSpecification` + `ToolExecutor`. + +Upgrade guide entry documenting the new `tags` parameter behavior. + +=== Step 3: Wire `camel-spring-ai-chat` producer + +Update `SpringAiChatProducer` to discover tools from `AiToolRegistry`. Adapter converts +`AiToolSpec` → `FunctionToolCallback`. Add `tags` parameter for filtering. + +Upgrade guide entry. + +=== Step 4: Wire `camel-langchain4j-tools` producer + +Update `LangChain4jToolsProducer` to read from `AiToolRegistry`, keeping the agentic +tool-call loop intact. + +=== Step 5: Deprecate old consumer endpoints + +* Deprecate `langchain4j-tools:` consumer side (registration) in favor of `ai-tool:` +* Remove `camel-spring-ai-tools` entirely (it was consumer-only; its functionality is + now in `camel-ai-tool`) + +Upgrade guide entries for both, with before/after migration examples. + +=== Step 6: Add tool support to `camel-openai` + +Add `tags` parameter to `OpenAIConfiguration`. The producer discovers tools from +`AiToolRegistry`, converts `AiToolSpec` → `ChatCompletionFunctionTool`, and dispatches +tool calls via `AiToolExecutor`. + +Separate JIRA issue for tracking. diff --git a/design/langchain4j-evolution.adoc b/design/langchain4j-evolution.adoc new file mode 100644 index 0000000000000..dd9b01addf9a0 --- /dev/null +++ b/design/langchain4j-evolution.adoc @@ -0,0 +1,385 @@ +--- +title: Camel LangChain4j — evolution, architecture and future direction +authors: + - "@zbendhiba" +reviewers: [] +approvers: [] +creation-date: 2026-07-08 +last-updated: 2026-07-08 +status: informational +see-also: + - "design/aiTool.adoc" + - "https://issues.apache.org/jira/browse/CAMEL-23382" +replaces: [] +superseded-by: [] +--- + +== Summary + +This document traces the evolution of LangChain4j integration in Apache Camel, from the +initial chat component through today's agent-based architecture. It explains *why* each +module exists, what worked, what did not, and where the project is heading — including +the open question of whether the LangChain4j layer should become Quarkus-native rather +than being maintained in Camel core. + +== Phase 1: LangChain4j Chat — the starting point + +The first LangChain4j component followed the standard LangChain4j documentation. It +mapped the basic chat model interaction to the Camel component model: + +* **`camel-langchain4j-chat`**: a producer to send messages to an LLM and a consumer to + define tool routes that the chat component can invoke. + +This was straightforward: one component, one concern. The user provides a `ChatLanguageModel` +bean, sends a message body, and gets a response. + +An early design decision was to **not create a separate component per model provider** +(no `camel-langchain4j-openai-chat`, no `camel-langchain4j-ollama-chat`). The reasoning +was that LangChain4j already abstracts over providers — the user just swaps the model +bean. This kept the component count manageable but created a configuration challenge that +would come back later (see <>). + +== Phase 2: Tools and Web Search — growing the ecosystem + +As LangChain4j added more capabilities, Camel followed: + +* **`camel-langchain4j-tools`**: a consumer endpoint that lets route authors define + Camel routes as LLM-callable tools. The LLM sees the tool description and parameters; + when it decides to call a tool, the corresponding route executes and returns the result. + Notably, this was designed as a separate component from chat — tools were used *alongside* + chat rather than *inside* it. The chat producer would discover tool consumers via a shared + cache and include them in the LLM request. + +* **`camel-langchain4j-web-search`**: a producer that lets the LLM search the web via + LangChain4j's web search engine abstraction (Google, Tavily, etc.). + +== Phase 3: Embeddings and Tokenization — the RAG building blocks + +In parallel, we added the components needed for Retrieval-Augmented Generation (RAG): + +* **`camel-langchain4j-embeddings`**: a producer that generates vector embeddings from + text using LangChain4j's embedding models. + +* **`camel-langchain4j-tokenizer`**: text splitters (by character, word, sentence, + paragraph, line) that use LangChain4j's tokenizer implementations. These are + model-specific: different LLMs have different tokenization rules, so the tokenizer + must match the model that will consume the chunks. + +=== The vector database problem + +To actually do RAG, embeddings need to be stored in and retrieved from a vector database. +Camel already had dedicated components for vector databases (Qdrant, Milvus, PgVector, +Pinecone, Neo4j, Weaviate, Infinispan), but these only handled basic CRUD operations. + +We added **LangChain4j embedding store** support to these components so that the +`camel-langchain4j-embeddings` producer could push embeddings directly into a vector +store using LangChain4j's `EmbeddingStore` API: + +* **`camel-langchain4j-embeddingstore`**: component for managing embeddings in vector + stores via LangChain4j's embedding store abstraction. +* **`camel-langchain4j-embeddingstore-api`**: the `EmbeddingStoreFactory` interface that + each vector database module implements. + +This approach involves some code duplication with LangChain4j. The reason is deliberate: +LangChain4j's embedding store implementations use the same properties and collection +names internally, so to reuse RAG data across LangChain4j and Camel, we need to write +embeddings the same way LangChain4j does. If we used our own storage format, RAG +retrieval through LangChain4j would not find the data. + +=== Vector search: harder than expected + +The reverse operation — searching for similar vectors — proved much harder. Each vector +database has its own similarity search API, and the quality of these Java APIs varies +wildly: + +* **Neo4j** was particularly painful: the similarity search code was essentially + incomprehensible, poorly documented, and the Java driver's vector search API was not + mature at all. Implementing it felt like copying opaque code that we could not + reasonably maintain. +* Other databases had similar friction, to varying degrees. + +We ended up needing to do vector search the same way we push embeddings: through +LangChain4j's `EmbeddingStore` abstraction, which handles the provider-specific +queries internally. + +The `camel-langchain4j-vector-search` module was started to address this but remains +incomplete. We decided not to ship half-baked vector search implementations that we +could not demo or maintain confidently. + +== Phase 4: The RAG and AIService gap + +With all these building blocks in place, there was still no way to do RAG end-to-end +from a single Camel component. The user had to manually orchestrate: split text, generate +embeddings, store them, then at query time retrieve similar documents and inject them +into the prompt. None of the existing components tied this together. + +Meanwhile, LangChain4j had introduced **AI Services** — a high-level abstraction that +combines chat, tools, memory, and RAG in a single interface. One method call, and +LangChain4j handles tool dispatch, conversation memory, document retrieval, and +response generation. + +=== RAG in chat + +One pragmatic step was adding RAG support directly to `camel-langchain4j-chat` via an +aggregation strategy (`LangChain4jRagAggregatorStrategy`). This lets a route fetch +documents from any source (including vector databases) and inject them as context +before the chat call. It works, but it is manual wiring — the user builds the RAG +pipeline in the route rather than having the component handle it. + +[[phase-5-forage]] +== Phase 5: Kaoto, Forage, and the visual agent challenge + +In parallel with the LangChain4j work, the project was thinking about visual tooling. +The goal: replicate the experience of tools like n8n, where a user can visually create +an AI agent and drag in the pieces it needs — LLM, vector database, memory, tools — all +without writing code. + +https://kaoto.io[Kaoto] is the visual editor for Camel routes. To make AI agents +visual in Kaoto, we needed a way to go from declarative properties to a fully configured +chat model, embedding model, memory store, etc. The problem is that we had deliberately +avoided creating per-provider components (see Phase 1), so there was no component-level +knob to say "use OpenAI" or "use Ollama" — that was left to the user's bean configuration. + +This led to the **Forage** project (https://github.com/KaotoIO/forage[KaotoIO/forage]): +a configuration layer that takes properties and generates the right LangChain4j model +beans. It bridges the gap between Kaoto's visual property panels and LangChain4j's +programmatic model construction. + +Forage solves the LLM configuration problem, but it is only the beginning. To fully +support a visual agent builder, Forage would need to handle: + +* Chat model configuration (done) +* Embedding model configuration +* Memory store configuration +* Vector store configuration +* RAG pipeline configuration +* Tool discovery and binding +* Guardrails +* MCP client configuration + +Each of these is a significant effort, and some aspects (security, guardrails, +dependency injection) are genuinely better handled by runtime frameworks like Quarkus +and Spring Boot rather than a configuration layer. + +== Phase 6: LangChain4j Agent — the AIService component + +To close the gap between Camel's building-block approach and LangChain4j's integrated +AI Service, a new component was created: + +* **`camel-langchain4j-agent`**: a producer that wraps LangChain4j's AI Service. It + handles chat, tools, memory, RAG, MCP clients, guardrails, and structured output in + a single component. + +NOTE: The name "agent" was chosen at a time when it felt like a fun way to position +Camel's AI capabilities alongside tools like n8n. However, LangChain4j has since +introduced its own concept called "Agent" (agentic workflows with planning and +autonomy), which is different from what `camel-langchain4j-agent` does. We may want to +rename it to `camel-langchain4j-aiservice` in the future to avoid confusion with +LangChain4j's own agent abstraction. + +=== What it needs + +The agent component needs *all* the bits: + +* **Chat model**: the LLM itself +* **Embedding stores**: for vector-based RAG, using LangChain4j's native embedding + store implementations +* **RAG**: LangChain4j's built-in retrieval augmentation +* **Tools**: Camel routes exposed as LLM-callable tools, Java methods annotated with + `@Tool`, and MCP tools +* **MCP clients**: Model Context Protocol servers providing external tools +* **Guardrails**: input and output validation (prompt injection detection, PII filtering, + keyword blocking, format validation, etc.) +* **Memory**: conversation memory via LangChain4j's `ChatMemory` or + `ChatMemoryProvider` interfaces +* **Structured output**: type-safe response parsing + +=== Supporting API modules + +* **`camel-langchain4j-agent-api`**: contains the `Agent`, `AgentConfiguration`, + `AgentFactory`, `Guardrails` interfaces and their implementations. This is the SPI + that lets users configure agents programmatically or via properties. +* **`camel-langchain4j-core`**: shared type converters used across LangChain4j + components. + +=== The configuration cliff + +The agent component exposes a rich feature set, but configuring it from Camel's +property-based model is extremely difficult. There is no standard way in Camel core +to configure: + +* An embedding store with its connection details, collection name, and embedding model +* A memory service with its backing store +* MCP server connections +* Guardrail chains +* RAG retrieval strategies + +Users who know LangChain4j can create these objects programmatically in Java and +register them as beans. But for the Camel CLI (`camel-jbang`), YAML DSL, or Kaoto +visual editing, there is no property-driven path to a fully configured agent. + +This is where Forage was supposed to help — but scaling Forage to cover all of this +is a very large effort (see <>). + +== Phase 7: Unified AI Tool abstraction + +The tool definition pattern was duplicated across `camel-langchain4j-tools`, +`camel-spring-ai-tools`, and `camel-langchain4j-agent`. Each had its own consumer +endpoint, registry, and specification types. + +**`camel-ai-tool`** unifies this into a single, framework-agnostic abstraction: + +* `AiToolSpec`: framework-agnostic tool descriptor +* `AiToolRegistry`: CamelContext-scoped registry (replaces all `CamelToolExecutorCache` + instances) +* `AiToolExecutor`: shared dispatch logic +* `ai-tool:` consumer endpoint: one way to define tools, usable by any AI producer + +See link:aiTool.adoc[design/aiTool.adoc] for the full design. + +== Current module map + +[cols="1,2,1"] +|=== +| Module | Purpose | Status + +| `camel-langchain4j-core` +| Shared converters +| Stable + +| `camel-langchain4j-chat` +| Chat with LLM (producer + consumer for tools) +| Stable + +| `camel-langchain4j-tools` +| Define Camel routes as LLM tools (consumer + producer) +| Being superseded by `camel-ai-tool` + +| `camel-langchain4j-web-search` +| Web search via LangChain4j +| Stable + +| `camel-langchain4j-embeddings` +| Generate vector embeddings +| Stable + +| `camel-langchain4j-embeddingstore` +| Store/retrieve embeddings in vector DBs +| Stable + +| `camel-langchain4j-embeddingstore-api` +| `EmbeddingStoreFactory` interface +| Stable + +| `camel-langchain4j-tokenizer` +| Model-specific text splitting +| Stable + +| `camel-langchain4j-agent` +| Full AI Service (chat + tools + memory + RAG + MCP + guardrails) +| Active development + +| `camel-langchain4j-agent-api` +| Agent interfaces, configuration, guardrails +| Active development + +| `camel-langchain4j-vector-search` +| Vector similarity search +| Incomplete/placeholder + +| `camel-ai-tool` +| Framework-agnostic tool abstraction +| Active development (CAMEL-23382) +|=== + +[[future-direction]] +== Future direction: Quarkus-native LangChain4j + +=== The maintainability question + +Today, Camel maintains its own LangChain4j integration layer. This means: + +* Reimplementing patterns that LangChain4j and its framework integrations already + provide (embedding store wiring, memory configuration, model construction) +* Maintaining vector database integration code that duplicates LangChain4j internals +* Building configuration infrastructure (Forage) that frameworks like Quarkus already + solve with CDI, configuration profiles, and extension mechanisms + +Meanwhile, the **Quarkus LangChain4j** extension +(https://github.com/quarkiverse/quarkus-langchain4j[quarkiverse/quarkus-langchain4j]) +already provides: + +* Per-provider extensions with full configuration support +* CDI-based model, memory, and embedding store injection +* Built-in guardrails as CDI beans +* Declarative AI Services via `@RegisterAiService` +* Integration with Quarkus security, observability, and dev services +* An active community with one of the main LangChain4j maintainers contributing + +=== The proposition + +If the LangChain4j components were made **Quarkus-native** — meaning they run on top of +the Quarkus LangChain4j extension rather than doing everything themselves — Camel would: + +* **Leverage the Quarkus LangChain4j ecosystem** for model configuration, memory, RAG, + guardrails, and all the infrastructure that is painful to maintain in Camel core +* **Focus on what Camel does best**: routing, EIP patterns, the component model, and + connecting AI to the 300+ integration endpoints +* **Avoid duplicating effort** on the configuration layer that Forage is trying to build + from scratch +* **Benefit from framework-level features** (security, dependency injection, dev services, + native compilation) that are fundamentally better handled at the framework level + +This does not mean abandoning the standalone Camel experience. The building-block +components (`camel-langchain4j-chat`, `camel-langchain4j-embeddings`, +`camel-langchain4j-web-search`, `camel-langchain4j-tokenizer`) work fine as-is and +serve users who want simple, focused integrations. + +The question is specifically about the **agent / AI Service** layer and the complex +configuration it requires. This is where the Quarkus LangChain4j ecosystem offers the +most value and where maintaining a parallel infrastructure in Camel core is the hardest +to justify. + +=== Open questions + +* What is the current status of Camel contributions to Quarkus LangChain4j? Are there + blockers or gaps that would prevent the Quarkus-native approach? +* Can we maintain a usable standalone experience (camel-jbang, plain Java) for simple + use cases while recommending Quarkus for the full agent experience? +* Should `camel-langchain4j-agent` be renamed to `camel-langchain4j-aiservice` to avoid + confusion with LangChain4j's own agent concept? +* What is the roadmap for Prince memory service and its Quarkus extension? Does it + affect the timeline for the Quarkus-native approach? +* How much of the Forage configuration layer should we continue building vs. deferring + to Quarkus? + +== Lessons learned + +[cols="1,2"] +|=== +| Decision | Outcome + +| No per-provider components +| Kept component count low but made visual tooling and property-based configuration + very difficult. Forage was created to bridge this gap. + +| Separate tools component from chat +| Clean separation but required a shared cache, which then got duplicated across + frameworks. Now being unified via `camel-ai-tool`. + +| Reusing LangChain4j embedding store internals +| Necessary for RAG data compatibility but means maintaining code that mirrors + LangChain4j's internal storage format. + +| Not shipping incomplete vector search +| Right call — the Neo4j experience showed that shipping code we cannot maintain or + demo is worse than shipping nothing. + +| The "agent" naming +| Made sense at the time (competitive positioning) but is now confusing given + LangChain4j's own agent concept. Rename is worth considering. + +| AI Service as a single component +| Solves the integration problem but creates a configuration problem that Camel's + property model cannot easily handle without framework support. +|=== diff --git a/docs/components/modules/ROOT/examples/json/ai-tool.json b/docs/components/modules/ROOT/examples/json/ai-tool.json new file mode 120000 index 0000000000000..ccee5b9fabc67 --- /dev/null +++ b/docs/components/modules/ROOT/examples/json/ai-tool.json @@ -0,0 +1 @@ +../../../../../../components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tool/ai-tool.json \ No newline at end of file diff --git a/docs/components/modules/ROOT/nav.adoc b/docs/components/modules/ROOT/nav.adoc index 8ae64ff6a3695..8eb41333f82de 100644 --- a/docs/components/modules/ROOT/nav.adoc +++ b/docs/components/modules/ROOT/nav.adoc @@ -6,6 +6,7 @@ ** xref:activemq6-component.adoc[ActiveMQ 6.x] ** xref:ai-summary.adoc[AI] *** xref:a2a-component.adoc[A2A] +*** xref:ai-tool-component.adoc[AI Tool] *** xref:chatscript-component.adoc[ChatScript] *** xref:djl-component.adoc[Deep Java Library] *** xref:docling-component.adoc[Docling] diff --git a/docs/components/modules/ROOT/pages/ai-tool-component.adoc b/docs/components/modules/ROOT/pages/ai-tool-component.adoc new file mode 120000 index 0000000000000..772c4ece801af --- /dev/null +++ b/docs/components/modules/ROOT/pages/ai-tool-component.adoc @@ -0,0 +1 @@ +../../../../../components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc \ No newline at end of file diff --git a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/ComponentsBuilderFactory.java b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/ComponentsBuilderFactory.java index 65c8356a2e7db..adcfab65d32fc 100644 --- a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/ComponentsBuilderFactory.java +++ b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/ComponentsBuilderFactory.java @@ -86,6 +86,20 @@ static ActivemqComponentBuilderFactory.ActivemqComponentBuilder activemq() { static Activemq6ComponentBuilderFactory.Activemq6ComponentBuilder activemq6() { return Activemq6ComponentBuilderFactory.activemq6(); } + /** + * AI Tool (camel-ai-tool) + * Framework-agnostic consumer endpoint that registers a Camel route as an + * LLM tool in the shared AiToolRegistry. + * + * Category: ai + * Since: 4.22 + * Maven coordinates: org.apache.camel:camel-ai-tool + * + * @return the dsl builder + */ + static AiToolComponentBuilderFactory.AiToolComponentBuilder aiTool() { + return AiToolComponentBuilderFactory.aiTool(); + } /** * AMQP (camel-amqp) * Messaging with AMQP protocol using Apache Qpid Client. diff --git a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/AiToolComponentBuilderFactory.java b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/AiToolComponentBuilderFactory.java new file mode 100644 index 0000000000000..c21d5d7193913 --- /dev/null +++ b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/AiToolComponentBuilderFactory.java @@ -0,0 +1,206 @@ +/* Generated by camel build tools - do NOT edit this file! */ +/* + * 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.builder.component.dsl; + +import javax.annotation.processing.Generated; +import org.apache.camel.Component; +import org.apache.camel.builder.component.AbstractComponentBuilder; +import org.apache.camel.builder.component.ComponentBuilder; +import org.apache.camel.component.ai.tool.AiToolComponent; + +/** + * Framework-agnostic consumer endpoint that registers a Camel route as an LLM + * tool in the shared AiToolRegistry. + * + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.ComponentDslMojo") +public interface AiToolComponentBuilderFactory { + + /** + * AI Tool (camel-ai-tool) + * Framework-agnostic consumer endpoint that registers a Camel route as an + * LLM tool in the shared AiToolRegistry. + * + * Category: ai + * Since: 4.22 + * Maven coordinates: org.apache.camel:camel-ai-tool + * + * @return the dsl builder + */ + static AiToolComponentBuilder aiTool() { + return new AiToolComponentBuilderImpl(); + } + + /** + * Builder for the AI Tool component. + */ + interface AiToolComponentBuilder extends ComponentBuilder { + + + /** + * Allows for bridging the consumer to the Camel routing Error Handler, + * which mean any exceptions (if possible) occurred while the Camel + * consumer is trying to pickup incoming messages, or the likes, will + * now be processed as a message and handled by the routing Error + * Handler. Important: This is only possible if the 3rd party component + * allows Camel to be alerted if an exception was thrown. Some + * components handle this internally only, and therefore + * bridgeErrorHandler is not possible. In other situations we may + * improve the Camel component to hook into the 3rd party component and + * make this possible for future releases. By default the consumer will + * use the org.apache.camel.spi.ExceptionHandler to deal with + * exceptions, that will be logged at WARN or ERROR level and ignored. + * + * The option is a: <code>boolean</code> type. + * + * Default: false + * Group: consumer + * + * @param bridgeErrorHandler the value to set + * @return the dsl builder + */ + default AiToolComponentBuilder bridgeErrorHandler(boolean bridgeErrorHandler) { + doSetProperty("bridgeErrorHandler", bridgeErrorHandler); + return this; + } + + /** + * The component configuration. + * + * The option is a: + * <code>org.apache.camel.component.ai.tool.AiToolConfiguration</code> type. + * + * Group: consumer + * + * @param configuration the value to set + * @return the dsl builder + */ + default AiToolComponentBuilder configuration(org.apache.camel.component.ai.tool.AiToolConfiguration configuration) { + doSetProperty("configuration", configuration); + return this; + } + + /** + * Human-readable description of what this tool does. Passed verbatim to + * the LLM; be precise and action-oriented. When omitted, defaults to + * the tool name. + * + * The option is a: <code>java.lang.String</code> type. + * + * Group: consumer + * + * @param description the value to set + * @return the dsl builder + */ + default AiToolComponentBuilder description(java.lang.String description) { + doSetProperty("description", description); + return this; + } + + /** + * Tool input parameters. Format: parameter.NAME=TYPE, + * parameter.NAME.description=TEXT, parameter.NAME.required=true or + * false, parameter.NAME.enum=val1,val2. Supported types: string, + * integer, number, boolean. This is a multi-value option with prefix: + * parameter. + * + * The option is a: <code>java.util.Map&lt;java.lang.String, + * java.lang.String&gt;</code> type. + * + * Group: consumer + * + * @param parameters the value to set + * @return the dsl builder + */ + default AiToolComponentBuilder parameters(java.util.Map parameters) { + doSetProperty("parameters", parameters); + return this; + } + + /** + * Comma-separated list of tags used to group tools. Producers filter + * the registry by these tags to select which tools to expose to the + * LLM. When omitted, the tool goes into a default pool available to all + * producers. + * + * The option is a: <code>java.lang.String</code> type. + * + * Group: consumer + * + * @param tags the value to set + * @return the dsl builder + */ + default AiToolComponentBuilder tags(java.lang.String tags) { + doSetProperty("tags", tags); + return this; + } + + + /** + * Whether autowiring is enabled. This is used for automatic autowiring + * options (the option must be marked as autowired) by looking up in the + * registry to find if there is a single instance of matching type, + * which then gets configured on the component. This can be used for + * automatic configuring JDBC data sources, JMS connection factories, + * AWS Clients, etc. + * + * The option is a: <code>boolean</code> type. + * + * Default: true + * Group: advanced + * + * @param autowiredEnabled the value to set + * @return the dsl builder + */ + default AiToolComponentBuilder autowiredEnabled(boolean autowiredEnabled) { + doSetProperty("autowiredEnabled", autowiredEnabled); + return this; + } + } + + class AiToolComponentBuilderImpl + extends AbstractComponentBuilder + implements AiToolComponentBuilder { + @Override + protected AiToolComponent buildConcreteComponent() { + return new AiToolComponent(); + } + private org.apache.camel.component.ai.tool.AiToolConfiguration getOrCreateConfiguration(AiToolComponent component) { + if (component.getConfiguration() == null) { + component.setConfiguration(new org.apache.camel.component.ai.tool.AiToolConfiguration()); + } + return component.getConfiguration(); + } + @Override + protected boolean setPropertyOnComponent( + Component component, + String name, + Object value) { + switch (name) { + case "bridgeErrorHandler": ((AiToolComponent) component).setBridgeErrorHandler((boolean) value); return true; + case "configuration": ((AiToolComponent) component).setConfiguration((org.apache.camel.component.ai.tool.AiToolConfiguration) value); return true; + case "description": getOrCreateConfiguration((AiToolComponent) component).setDescription((java.lang.String) value); return true; + case "parameters": getOrCreateConfiguration((AiToolComponent) component).setParameters((java.util.Map) value); return true; + case "tags": getOrCreateConfiguration((AiToolComponent) component).setTags((java.lang.String) value); return true; + case "autowiredEnabled": ((AiToolComponent) component).setAutowiredEnabled((boolean) value); return true; + default: return false; + } + } + } +} \ No newline at end of file diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointBuilderFactory.java index c3955a8470866..05ae1039f3b05 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointBuilderFactory.java @@ -37,6 +37,7 @@ public interface EndpointBuilderFactory org.apache.camel.builder.endpoint.dsl.AWSConfigEndpointBuilderFactory.AWSConfigBuilders, org.apache.camel.builder.endpoint.dsl.ActiveMQ6EndpointBuilderFactory.ActiveMQ6Builders, org.apache.camel.builder.endpoint.dsl.ActiveMQEndpointBuilderFactory.ActiveMQBuilders, + org.apache.camel.builder.endpoint.dsl.AiToolEndpointBuilderFactory.AiToolBuilders, org.apache.camel.builder.endpoint.dsl.ArangoDbEndpointBuilderFactory.ArangoDbBuilders, org.apache.camel.builder.endpoint.dsl.AsteriskEndpointBuilderFactory.AsteriskBuilders, org.apache.camel.builder.endpoint.dsl.Athena2EndpointBuilderFactory.Athena2Builders, diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointBuilders.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointBuilders.java index 8e65ce0fbf404..ac8eb7ace0dee 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointBuilders.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointBuilders.java @@ -34,6 +34,7 @@ public interface EndpointBuilders org.apache.camel.builder.endpoint.dsl.AWSConfigEndpointBuilderFactory, org.apache.camel.builder.endpoint.dsl.ActiveMQ6EndpointBuilderFactory, org.apache.camel.builder.endpoint.dsl.ActiveMQEndpointBuilderFactory, + org.apache.camel.builder.endpoint.dsl.AiToolEndpointBuilderFactory, org.apache.camel.builder.endpoint.dsl.ArangoDbEndpointBuilderFactory, org.apache.camel.builder.endpoint.dsl.AsteriskEndpointBuilderFactory, org.apache.camel.builder.endpoint.dsl.Athena2EndpointBuilderFactory, diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/StaticEndpointBuilders.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/StaticEndpointBuilders.java index 4941f9f1ac739..8a04b43ce2eb8 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/StaticEndpointBuilders.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/StaticEndpointBuilders.java @@ -176,6 +176,48 @@ public static ActiveMQ6EndpointBuilderFactory.ActiveMQ6EndpointBuilder activemq6 public static ActiveMQ6EndpointBuilderFactory.ActiveMQ6EndpointBuilder activemq6(String componentName, String path) { return ActiveMQ6EndpointBuilderFactory.endpointBuilder(componentName, path); } + /** + * AI Tool (camel-ai-tool) + * Framework-agnostic consumer endpoint that registers a Camel route as an + * LLM tool in the shared AiToolRegistry. + * + * Category: ai + * Since: 4.22 + * Maven coordinates: org.apache.camel:camel-ai-tool + * + * Syntax: ai-tool:toolName + * + * Path parameter: toolName (required) + * The tool name. This is the name the LLM sees and uses to invoke the tool. + * + * @param path toolName + * @return the dsl builder + */ + public static AiToolEndpointBuilderFactory.AiToolEndpointBuilder aiTool(String path) { + return aiTool("ai-tool", path); + } + /** + * AI Tool (camel-ai-tool) + * Framework-agnostic consumer endpoint that registers a Camel route as an + * LLM tool in the shared AiToolRegistry. + * + * Category: ai + * Since: 4.22 + * Maven coordinates: org.apache.camel:camel-ai-tool + * + * Syntax: ai-tool:toolName + * + * Path parameter: toolName (required) + * The tool name. This is the name the LLM sees and uses to invoke the tool. + * + * @param componentName to use a custom component name for the endpoint + * instead of the default name + * @param path toolName + * @return the dsl builder + */ + public static AiToolEndpointBuilderFactory.AiToolEndpointBuilder aiTool(String componentName, String path) { + return AiToolEndpointBuilderFactory.endpointBuilder(componentName, path); + } /** * AMQP (camel-amqp) * Messaging with AMQP protocol using Apache Qpid Client. diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/AiToolEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/AiToolEndpointBuilderFactory.java new file mode 100644 index 0000000000000..e0828c82a002d --- /dev/null +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/AiToolEndpointBuilderFactory.java @@ -0,0 +1,312 @@ +/* Generated by camel build tools - do NOT edit this file! */ +/* + * 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.builder.endpoint.dsl; + +import java.util.*; +import java.util.concurrent.*; +import java.util.function.*; +import java.util.stream.*; +import javax.annotation.processing.Generated; +import org.apache.camel.builder.EndpointConsumerBuilder; +import org.apache.camel.builder.EndpointProducerBuilder; +import org.apache.camel.builder.endpoint.AbstractEndpointBuilder; + +/** + * Framework-agnostic consumer endpoint that registers a Camel route as an LLM + * tool in the shared AiToolRegistry. + * + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.EndpointDslMojo") +public interface AiToolEndpointBuilderFactory { + + /** + * Builder for endpoint for the AI Tool component. + */ + public interface AiToolEndpointBuilder + extends + EndpointConsumerBuilder { + default AdvancedAiToolEndpointBuilder advanced() { + return (AdvancedAiToolEndpointBuilder) this; + } + + /** + * Human-readable description of what this tool does. Passed verbatim to + * the LLM; be precise and action-oriented. When omitted, defaults to + * the tool name. + * + * The option is a: java.lang.String type. + * + * Group: consumer + * + * @param description the value to set + * @return the dsl builder + */ + default AiToolEndpointBuilder description(String description) { + doSetProperty("description", description); + return this; + } + /** + * Tool input parameters. Format: parameter.NAME=TYPE, + * parameter.NAME.description=TEXT, parameter.NAME.required=true or + * false, parameter.NAME.enum=val1,val2. Supported types: string, + * integer, number, boolean. This is a multi-value option with prefix: + * parameter. + * + * The option is a: java.util.Map<java.lang.String, + * java.lang.String> type. + * The option is multivalued, and you can use the parameters(String, + * Object) method to add a value (call the method multiple times to set + * more values). + * + * Group: consumer + * + * @param key the option key + * @param value the option value + * @return the dsl builder + */ + default AiToolEndpointBuilder parameters(String key, Object value) { + doSetMultiValueProperty("parameters", "parameter." + key, value); + return this; + } + /** + * Tool input parameters. Format: parameter.NAME=TYPE, + * parameter.NAME.description=TEXT, parameter.NAME.required=true or + * false, parameter.NAME.enum=val1,val2. Supported types: string, + * integer, number, boolean. This is a multi-value option with prefix: + * parameter. + * + * The option is a: java.util.Map<java.lang.String, + * java.lang.String> type. + * The option is multivalued, and you can use the parameters(String, + * Object) method to add a value (call the method multiple times to set + * more values). + * + * Group: consumer + * + * @param values the values + * @return the dsl builder + */ + default AiToolEndpointBuilder parameters(Map values) { + doSetMultiValueProperties("parameters", "parameter.", values); + return this; + } + /** + * Comma-separated list of tags used to group tools. Producers filter + * the registry by these tags to select which tools to expose to the + * LLM. When omitted, the tool goes into a default pool available to all + * producers. + * + * The option is a: java.lang.String type. + * + * Group: consumer + * + * @param tags the value to set + * @return the dsl builder + */ + default AiToolEndpointBuilder tags(String tags) { + doSetProperty("tags", tags); + return this; + } + } + + /** + * Advanced builder for endpoint for the AI Tool component. + */ + public interface AdvancedAiToolEndpointBuilder + extends + EndpointConsumerBuilder { + default AiToolEndpointBuilder basic() { + return (AiToolEndpointBuilder) this; + } + + /** + * Allows for bridging the consumer to the Camel routing Error Handler, + * which mean any exceptions (if possible) occurred while the Camel + * consumer is trying to pickup incoming messages, or the likes, will + * now be processed as a message and handled by the routing Error + * Handler. Important: This is only possible if the 3rd party component + * allows Camel to be alerted if an exception was thrown. Some + * components handle this internally only, and therefore + * bridgeErrorHandler is not possible. In other situations we may + * improve the Camel component to hook into the 3rd party component and + * make this possible for future releases. By default the consumer will + * use the org.apache.camel.spi.ExceptionHandler to deal with + * exceptions, that will be logged at WARN or ERROR level and ignored. + * + * The option is a: boolean type. + * + * Default: false + * Group: consumer (advanced) + * + * @param bridgeErrorHandler the value to set + * @return the dsl builder + */ + default AdvancedAiToolEndpointBuilder bridgeErrorHandler(boolean bridgeErrorHandler) { + doSetProperty("bridgeErrorHandler", bridgeErrorHandler); + return this; + } + /** + * Allows for bridging the consumer to the Camel routing Error Handler, + * which mean any exceptions (if possible) occurred while the Camel + * consumer is trying to pickup incoming messages, or the likes, will + * now be processed as a message and handled by the routing Error + * Handler. Important: This is only possible if the 3rd party component + * allows Camel to be alerted if an exception was thrown. Some + * components handle this internally only, and therefore + * bridgeErrorHandler is not possible. In other situations we may + * improve the Camel component to hook into the 3rd party component and + * make this possible for future releases. By default the consumer will + * use the org.apache.camel.spi.ExceptionHandler to deal with + * exceptions, that will be logged at WARN or ERROR level and ignored. + * + * The option will be converted to a boolean type. + * + * Default: false + * Group: consumer (advanced) + * + * @param bridgeErrorHandler the value to set + * @return the dsl builder + */ + default AdvancedAiToolEndpointBuilder bridgeErrorHandler(String bridgeErrorHandler) { + doSetProperty("bridgeErrorHandler", bridgeErrorHandler); + return this; + } + /** + * To let the consumer use a custom ExceptionHandler. Notice if the + * option bridgeErrorHandler is enabled then this option is not in use. + * By default the consumer will deal with exceptions, that will be + * logged at WARN or ERROR level and ignored. + * + * The option is a: org.apache.camel.spi.ExceptionHandler + * type. + * + * Group: consumer (advanced) + * + * @param exceptionHandler the value to set + * @return the dsl builder + */ + default AdvancedAiToolEndpointBuilder exceptionHandler(org.apache.camel.spi.ExceptionHandler exceptionHandler) { + doSetProperty("exceptionHandler", exceptionHandler); + return this; + } + /** + * To let the consumer use a custom ExceptionHandler. Notice if the + * option bridgeErrorHandler is enabled then this option is not in use. + * By default the consumer will deal with exceptions, that will be + * logged at WARN or ERROR level and ignored. + * + * The option will be converted to a + * org.apache.camel.spi.ExceptionHandler type. + * + * Group: consumer (advanced) + * + * @param exceptionHandler the value to set + * @return the dsl builder + */ + default AdvancedAiToolEndpointBuilder exceptionHandler(String exceptionHandler) { + doSetProperty("exceptionHandler", exceptionHandler); + return this; + } + /** + * Sets the exchange pattern when the consumer creates an exchange. + * + * The option is a: org.apache.camel.ExchangePattern type. + * + * Group: consumer (advanced) + * + * @param exchangePattern the value to set + * @return the dsl builder + */ + default AdvancedAiToolEndpointBuilder exchangePattern(org.apache.camel.ExchangePattern exchangePattern) { + doSetProperty("exchangePattern", exchangePattern); + return this; + } + /** + * Sets the exchange pattern when the consumer creates an exchange. + * + * The option will be converted to a + * org.apache.camel.ExchangePattern type. + * + * Group: consumer (advanced) + * + * @param exchangePattern the value to set + * @return the dsl builder + */ + default AdvancedAiToolEndpointBuilder exchangePattern(String exchangePattern) { + doSetProperty("exchangePattern", exchangePattern); + return this; + } + } + + public interface AiToolBuilders { + /** + * AI Tool (camel-ai-tool) + * Framework-agnostic consumer endpoint that registers a Camel route as + * an LLM tool in the shared AiToolRegistry. + * + * Category: ai + * Since: 4.22 + * Maven coordinates: org.apache.camel:camel-ai-tool + * + * Syntax: ai-tool:toolName + * + * Path parameter: toolName (required) + * The tool name. This is the name the LLM sees and uses to invoke the + * tool. + * + * @param path toolName + * @return the dsl builder + */ + default AiToolEndpointBuilder aiTool(String path) { + return AiToolEndpointBuilderFactory.endpointBuilder("ai-tool", path); + } + /** + * AI Tool (camel-ai-tool) + * Framework-agnostic consumer endpoint that registers a Camel route as + * an LLM tool in the shared AiToolRegistry. + * + * Category: ai + * Since: 4.22 + * Maven coordinates: org.apache.camel:camel-ai-tool + * + * Syntax: ai-tool:toolName + * + * Path parameter: toolName (required) + * The tool name. This is the name the LLM sees and uses to invoke the + * tool. + * + * @param componentName to use a custom component name for the endpoint + * instead of the default name + * @param path toolName + * @return the dsl builder + */ + default AiToolEndpointBuilder aiTool(String componentName, String path) { + return AiToolEndpointBuilderFactory.endpointBuilder(componentName, path); + } + + } + static AiToolEndpointBuilder endpointBuilder(String componentName, String path) { + class AiToolEndpointBuilderImpl extends AbstractEndpointBuilder implements AiToolEndpointBuilder, AdvancedAiToolEndpointBuilder { + public AiToolEndpointBuilderImpl(String path) { + super(componentName, path); + } + } + return new AiToolEndpointBuilderImpl(path); + } +} \ No newline at end of file diff --git a/dsl/camel-kamelet-main/src/generated/resources/camel-component-known-dependencies.properties b/dsl/camel-kamelet-main/src/generated/resources/camel-component-known-dependencies.properties index bfb34d11728af..1e819aadf38ee 100644 --- a/dsl/camel-kamelet-main/src/generated/resources/camel-component-known-dependencies.properties +++ b/dsl/camel-kamelet-main/src/generated/resources/camel-component-known-dependencies.properties @@ -20,6 +20,7 @@ org.apache.camel.coap.CoAPComponent=camel:coap org.apache.camel.component.a2a.A2AComponent=camel:a2a org.apache.camel.component.activemq.ActiveMQComponent=camel:activemq org.apache.camel.component.activemq6.ActiveMQComponent=camel:activemq6 +org.apache.camel.component.ai.tool.AiToolComponent=camel:ai-tool org.apache.camel.component.amqp.AMQPComponent=camel:amqp org.apache.camel.component.arangodb.ArangoDbComponent=camel:arangodb org.apache.camel.component.as2.AS2Component=camel:as2 diff --git a/parent/pom.xml b/parent/pom.xml index e00ba4a35eb07..c20556c917ec2 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -714,6 +714,11 @@ camel-activemq6 ${project.version} + + org.apache.camel + camel-ai-tool + ${project.version} + org.apache.camel camel-amqp diff --git a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java index 06475b119b511..2d26ee04cf306 100644 --- a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java +++ b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java @@ -38,7 +38,8 @@ private MojoHelper() { public static List getComponentPath(Path dir) { switch (dir.getFileName().toString()) { case "camel-ai": - return Arrays.asList(dir.resolve("camel-a2a"), dir.resolve("camel-chatscript"), dir.resolve("camel-djl"), + return Arrays.asList(dir.resolve("camel-a2a"), dir.resolve("camel-ai-tool"), + dir.resolve("camel-chatscript"), dir.resolve("camel-djl"), dir.resolve("camel-huggingface"), dir.resolve("camel-langchain4j-agent"), dir.resolve("camel-langchain4j-chat"), dir.resolve("camel-langchain4j-embeddings"), dir.resolve("camel-langchain4j-embeddingstore"),