From 7abda61f7a67bfc950516d279d11715138764806 Mon Sep 17 00:00:00 2001 From: Zineb Bendhiba Date: Fri, 3 Jul 2026 10:29:30 +0200 Subject: [PATCH 1/5] CAMEL-23382: camel-ai-tool - create unified AI tool component Step 1: Create the new camel-ai-tool module with AiToolSpec, AiToolRegistry, consumer endpoint, and full tests. No changes to existing modules. Co-Authored-By: Claude Opus 4.6 --- bom/camel-bom/pom.xml | 5 + catalog/camel-allcomponents/pom.xml | 5 + .../camel/catalog/components.properties | 1 + .../camel/catalog/components/ai-tool.json | 43 ++ components/camel-ai/camel-ai-tool/pom.xml | 66 +++ .../ai/tools/AiToolComponentConfigurer.java | 84 +++ .../tools/AiToolConfigurationConfigurer.java | 62 ++ .../ai/tools/AiToolEndpointConfigurer.java | 80 +++ .../ai/tools/AiToolEndpointUriFactory.java | 85 +++ .../camel/component/ai/tools/ai-tool.json | 43 ++ .../org/apache/camel/component.properties | 7 + .../org/apache/camel/component/ai-tool | 2 + .../apache/camel/configurer/ai-tool-component | 2 + .../apache/camel/configurer/ai-tool-endpoint | 2 + ...mel.component.ai.tools.AiToolConfiguration | 2 + .../apache/camel/urifactory/ai-tool-endpoint | 2 + .../src/main/docs/ai-tool-component.adoc | 241 ++++++++ .../camel/component/ai/tools/AiTool.java | 35 ++ .../component/ai/tools/AiToolArguments.java | 124 ++++ .../component/ai/tools/AiToolComponent.java | 83 +++ .../ai/tools/AiToolConfiguration.java | 94 +++ .../component/ai/tools/AiToolConsumer.java | 100 ++++ .../component/ai/tools/AiToolEndpoint.java | 91 +++ .../component/ai/tools/AiToolExecutor.java | 143 +++++ .../ai/tools/AiToolParameterHelper.java | 230 ++++++++ .../component/ai/tools/AiToolRegistry.java | 171 ++++++ .../component/ai/tools/AiToolResult.java | 59 ++ .../camel/component/ai/tools/AiToolSpec.java | 112 ++++ .../ai/tools/AiToolEndpointLifecycleTest.java | 285 +++++++++ .../ai/tools/AiToolExecutorTest.java | 357 ++++++++++++ .../ai/tools/AiToolParameterHelperTest.java | 269 +++++++++ .../ai/tools/AiToolRegistryTest.java | 231 ++++++++ .../src/test/resources/log4j2.properties | 30 + components/camel-ai/pom.xml | 1 + .../apache/camel/main/components.properties | 1 + design/aiTool.adoc | 543 ++++++++++++++++++ .../modules/ROOT/examples/json/ai-tool.json | 1 + docs/components/modules/ROOT/nav.adoc | 1 + .../modules/ROOT/pages/ai-tool-component.adoc | 1 + .../component/ComponentsBuilderFactory.java | 14 + .../dsl/AiToolComponentBuilderFactory.java | 206 +++++++ .../endpoint/EndpointBuilderFactory.java | 1 + .../builder/endpoint/EndpointBuilders.java | 1 + .../endpoint/StaticEndpointBuilders.java | 42 ++ .../dsl/AiToolEndpointBuilderFactory.java | 312 ++++++++++ ...el-component-known-dependencies.properties | 1 + parent/pom.xml | 5 + .../camel/maven/packaging/MojoHelper.java | 3 +- 48 files changed, 4278 insertions(+), 1 deletion(-) create mode 100644 catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ai-tool.json create mode 100644 components/camel-ai/camel-ai-tool/pom.xml create mode 100644 components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolComponentConfigurer.java create mode 100644 components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolConfigurationConfigurer.java create mode 100644 components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolEndpointConfigurer.java create mode 100644 components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolEndpointUriFactory.java create mode 100644 components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tools/ai-tool.json create mode 100644 components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/component.properties create mode 100644 components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/component/ai-tool create mode 100644 components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/configurer/ai-tool-component create mode 100644 components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/configurer/ai-tool-endpoint create mode 100644 components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.ai.tools.AiToolConfiguration create mode 100644 components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/urifactory/ai-tool-endpoint create mode 100644 components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc create mode 100644 components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiTool.java create mode 100644 components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolArguments.java create mode 100644 components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolComponent.java create mode 100644 components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolConfiguration.java create mode 100644 components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolConsumer.java create mode 100644 components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolEndpoint.java create mode 100644 components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolExecutor.java create mode 100644 components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolParameterHelper.java create mode 100644 components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolRegistry.java create mode 100644 components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolResult.java create mode 100644 components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolSpec.java create mode 100644 components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolEndpointLifecycleTest.java create mode 100644 components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolExecutorTest.java create mode 100644 components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolParameterHelperTest.java create mode 100644 components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolRegistryTest.java create mode 100644 components/camel-ai/camel-ai-tool/src/test/resources/log4j2.properties create mode 100644 design/aiTool.adoc create mode 120000 docs/components/modules/ROOT/examples/json/ai-tool.json create mode 120000 docs/components/modules/ROOT/pages/ai-tool-component.adoc create mode 100644 dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/AiToolComponentBuilderFactory.java create mode 100644 dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/AiToolEndpointBuilderFactory.java 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..27638cc107998 --- /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.tools.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.tools.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.tools.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.tools.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": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tools.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.tools.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.tools.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": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tools.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/tools/AiToolComponentConfigurer.java b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolComponentConfigurer.java new file mode 100644 index 0000000000000..c1a88a372a702 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolComponentConfigurer.java @@ -0,0 +1,84 @@ +/* Generated by camel build tools - do NOT edit this file! */ +package org.apache.camel.component.ai.tools; + +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.tools.AiToolConfiguration getOrCreateConfiguration(AiToolComponent target) { + if (target.getConfiguration() == null) { + target.setConfiguration(new org.apache.camel.component.ai.tools.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.tools.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.tools.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/tools/AiToolConfigurationConfigurer.java b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolConfigurationConfigurer.java new file mode 100644 index 0000000000000..11b3197e9bc3e --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolConfigurationConfigurer.java @@ -0,0 +1,62 @@ +/* Generated by camel build tools - do NOT edit this file! */ +package org.apache.camel.component.ai.tools; + +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.tools.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.tools.AiToolConfiguration target = (org.apache.camel.component.ai.tools.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.tools.AiToolConfiguration target = (org.apache.camel.component.ai.tools.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/tools/AiToolEndpointConfigurer.java b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolEndpointConfigurer.java new file mode 100644 index 0000000000000..d869ffec43e89 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolEndpointConfigurer.java @@ -0,0 +1,80 @@ +/* Generated by camel build tools - do NOT edit this file! */ +package org.apache.camel.component.ai.tools; + +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/tools/AiToolEndpointUriFactory.java b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolEndpointUriFactory.java new file mode 100644 index 0000000000000..88e7ecd66999e --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolEndpointUriFactory.java @@ -0,0 +1,85 @@ +/* Generated by camel build tools - do NOT edit this file! */ +package org.apache.camel.component.ai.tools; + +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/tools/ai-tool.json b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tools/ai-tool.json new file mode 100644 index 0000000000000..27638cc107998 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tools/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.tools.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.tools.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.tools.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.tools.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": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tools.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.tools.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.tools.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": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tools.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..0ec753438a8a4 --- /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.tools.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..de6cdf5765971 --- /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.tools.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..1f1f4e2e88e23 --- /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.tools.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.tools.AiToolConfiguration b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.ai.tools.AiToolConfiguration new file mode 100644 index 0000000000000..82fcec9e39192 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.ai.tools.AiToolConfiguration @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.component.ai.tools.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..c2ba1e31f34ee --- /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.tools.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..f574d1d2f577c --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc @@ -0,0 +1,241 @@ += 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 provides a framework-agnostic way to expose Camel routes as tools that AI models can call. +Tools registered via this component are stored in a shared `AiToolRegistry` and can be discovered by any AI producer +component (such as xref:langchain4j-agent-component.adoc[LangChain4j Agent] or xref:spring-ai-chat-component.adoc[Spring AI Chat]) +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 + +Define a tool by creating a consumer route with the `ai-tool:` scheme. +The route body processes tool invocations and returns results to the AI model. + +=== 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:queryUser?tags=users&description=Query database by user ID" + + "¶meter.id=integer¶meter.id.description=The user ID¶meter.id.required=true") + .to("sql:SELECT name FROM users WHERE id = :#id"); + +// LangChain4j agent uses only weather-tagged tools +from("direct:chat") + .to("langchain4j-agent:assistant?agent=#myAgent&tags=weather"); +---- + +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:queryUser + parameters: + tags: users + description: "Query database by user ID" + parameter.id: integer + parameter.id.description: "The user ID" + parameter.id.required: true + steps: + - to: + uri: "sql:SELECT name FROM users WHERE id = :#id" + +# LangChain4j agent uses only weather-tagged tools +- route: + from: + uri: direct:chat + steps: + - to: + uri: "langchain4j-agent:assistant?agent=#myAgent&tags=weather" +---- +==== + +== 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/tools/AiTool.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiTool.java new file mode 100644 index 0000000000000..13a71a24d6d75 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiTool.java @@ -0,0 +1,35 @@ +/* + * 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.tools; + +/** + * Constants for the AI Tool component. + * + * @since 4.22 + */ +public final class AiTool { + + public static final String SCHEME = "ai-tool"; + + /** + * Exchange variable name under which {@link AiToolArguments} is set by the executor. + */ + public static final String TOOL_ARGUMENTS = "AiToolArguments"; + + private AiTool() { + } +} diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolArguments.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolArguments.java new file mode 100644 index 0000000000000..84d2c7f14a6a3 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolArguments.java @@ -0,0 +1,124 @@ +/* + * 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.tools; + +import java.util.Collections; +import java.util.Map; + +/** + * Typed wrapper around the tool arguments sent by the LLM. Set as an exchange variable by {@link AiToolExecutor} under + * the key {@link AiTool#TOOL_ARGUMENTS}, so route authors can retrieve it with: + * + *
{@code
+ * AiToolArguments args = exchange.getVariable(AiTool.TOOL_ARGUMENTS, AiToolArguments.class);
+ * String city = args.getString("city");
+ * }
+ * + * @since 4.22 + */ +public final class AiToolArguments { + + private final String toolName; + private final Map parameters; + + public AiToolArguments(String toolName, Map parameters) { + this.toolName = toolName; + this.parameters = parameters != null ? Collections.unmodifiableMap(parameters) : Map.of(); + } + + public String getToolName() { + return toolName; + } + + public Map getParameters() { + return parameters; + } + + public Object get(String name) { + return parameters.get(name); + } + + public String getString(String name) { + Object value = parameters.get(name); + return value != null ? value.toString() : null; + } + + /** + * Returns the argument as an Integer, or {@code null} if absent or not convertible. + */ + public Integer getInteger(String name) { + Object value = parameters.get(name); + if (value instanceof Number n) { + return n.intValue(); + } + if (value instanceof String s) { + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + /** + * Returns the argument as a Double, or {@code null} if absent or not convertible. + */ + public Double getDouble(String name) { + Object value = parameters.get(name); + if (value instanceof Number n) { + return n.doubleValue(); + } + if (value instanceof String s) { + try { + return Double.parseDouble(s); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + /** + * Returns the argument as a Boolean, or {@code null} if absent or not convertible. + */ + public Boolean getBoolean(String name) { + Object value = parameters.get(name); + if (value instanceof Boolean b) { + return b; + } + if (value instanceof String s) { + if ("true".equalsIgnoreCase(s)) { + return Boolean.TRUE; + } + if ("false".equalsIgnoreCase(s)) { + return Boolean.FALSE; + } + return null; + } + return null; + } + + public boolean has(String name) { + return parameters.containsKey(name); + } + + @Override + public String toString() { + return "AiToolArguments{toolName=" + toolName + ", parameters=" + parameters + '}'; + } +} diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolComponent.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolComponent.java new file mode 100644 index 0000000000000..701b550002cf2 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolComponent.java @@ -0,0 +1,83 @@ +/* + * 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.tools; + +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 org.apache.camel.util.StringHelper; + +import static org.apache.camel.component.ai.tools.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="); + } + + final String toolName = StringHelper.before(remaining, "/", 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/tools/AiToolConfiguration.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolConfiguration.java new file mode 100644 index 0000000000000..24b14b4507508 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolConfiguration.java @@ -0,0 +1,94 @@ +/* + * 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.tools; + +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 { + + @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/tools/AiToolConsumer.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolConsumer.java new file mode 100644 index 0000000000000..5c785eaad8e72 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolConsumer.java @@ -0,0 +1,100 @@ +/* + * 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.tools; + +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; + + registeredSpec = new AiToolSpec( + toolName, configuration.getDescription(), parameterDefs, jsonSchema, this); + + AiToolRegistry registry = AiToolRegistry.getOrCreate(getEndpoint().getCamelContext()); + String tags = configuration.getTags(); + if (tags != null && !tags.isBlank()) { + registeredTags = AiToolParameterHelper.splitTags(tags); + registeredInDefaultPool = false; + for (String tag : registeredTags) { + LOG.debug("Registering tool '{}' with tag '{}'", toolName, tag); + registry.put(tag, registeredSpec); + } + } else { + registeredTags = null; + registeredInDefaultPool = true; + LOG.debug("Registering tool '{}' in default pool (no tags)", toolName); + registry.putDefault(registeredSpec); + } + } + + @Override + protected void doStop() throws Exception { + if (registeredSpec != null) { + 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); + } + registeredSpec = null; + registeredTags = null; + registeredInDefaultPool = false; + } + super.doStop(); + } +} diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolEndpoint.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolEndpoint.java new file mode 100644 index 0000000000000..f6d295ca45e32 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolEndpoint.java @@ -0,0 +1,91 @@ +/* + * 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.tools; + +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.tools.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 { + if (configuration.getDescription() == null || configuration.getDescription().isBlank()) { + configuration.setDescription(toolName); + } + + 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/tools/AiToolExecutor.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolExecutor.java new file mode 100644 index 0000000000000..946f4a43cefa0 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolExecutor.java @@ -0,0 +1,143 @@ +/* + * 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.tools; + +import java.util.HashMap; +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 warned about but not + * rejected, required arguments are checked) and then wrapped in an {@link AiToolArguments} object set as an + * exchange variable under {@link AiTool#TOOL_ARGUMENTS}. This avoids polluting the exchange header namespace and + * eliminates any risk of colliding with internal Camel headers. + *

+ * 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); + + // Warn about undeclared arguments but do not reject them -- LLMs frequently + // hallucinate extra parameters and rejecting them would break valid tool calls. + if (arguments != null && !arguments.isEmpty() && !spec.getParameterDefs().isEmpty()) { + Set declaredParams = spec.getParameterDefs().keySet(); + + for (String name : arguments.keySet()) { + if (!declaredParams.contains(name)) { + LOG.warn("Undeclared tool argument '{}' for tool '{}' -- the LLM sent a parameter " + + "that is not declared in the tool specification; ignoring it", + name, toolName); + } + } + } + + 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); + } + } + + // Defensive copy so callers cannot mutate arguments during route execution + Map argsCopy = arguments != null ? new HashMap<>(arguments) : Map.of(); + exchange.setVariable(AiTool.TOOL_ARGUMENTS, new AiToolArguments(toolName, argsCopy)); + + // Execute the route + try { + routeProcessor.process(exchange); + } 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); + } + + 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"); + } +} diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolParameterHelper.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolParameterHelper.java new file mode 100644 index 0000000000000..c56557a81c6c0 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolParameterHelper.java @@ -0,0 +1,230 @@ +/* + * 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.tools; + +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 tagList.trim().split("\\s*,\\s*"); + } + + /** + * 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/tools/AiToolRegistry.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolRegistry.java new file mode 100644 index 0000000000000..2fdb6d3de6454 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolRegistry.java @@ -0,0 +1,171 @@ +/* + * 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.tools; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.camel.CamelContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * CamelContext-scoped registry mapping tags to {@link AiToolSpec} instances. AI components (LangChain4j, Spring AI) + * 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 Logger LOG = LoggerFactory.getLogger(AiToolRegistry.class); + + private final Map> tools; + private final Set defaultTools; + + AiToolRegistry() { + tools = new ConcurrentHashMap<>(); + defaultTools = Collections.synchronizedSet(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) { + synchronized (context) { + AiToolRegistry registry = context.getCamelContextExtension() + .getContextPlugin(AiToolRegistry.class); + if (registry == null) { + registry = new AiToolRegistry(); + context.getCamelContextExtension() + .addContextPlugin(AiToolRegistry.class, registry); + } + return registry; + } + } + + public void put(String tag, AiToolSpec spec) { + tools.compute(tag, (k, set) -> { + if (set == null) { + set = Collections.synchronizedSet(new LinkedHashSet<>()); + } + synchronized (set) { + for (AiToolSpec existing : set) { + if (existing.getName().equals(spec.getName()) && existing != spec) { + LOG.warn("Duplicate toolName '{}' under tag '{}' -- the LLM adapter will see both " + + "and may not be able to distinguish them", + spec.getName(), tag); + break; + } + } + set.add(spec); + } + return set; + }); + } + + public void remove(String tag, AiToolSpec spec) { + tools.compute(tag, (k, set) -> { + if (set != null) { + synchronized (set) { + set.remove(spec); + if (set.isEmpty()) { + return null; + } + } + } + return set; + }); + } + + public void putDefault(AiToolSpec spec) { + synchronized (defaultTools) { + for (AiToolSpec existing : defaultTools) { + if (existing.getName().equals(spec.getName()) && existing != spec) { + LOG.warn("Duplicate toolName '{}' in the default pool -- the LLM adapter will see both " + + "and may not be able to distinguish them", + spec.getName()); + break; + } + } + defaultTools.add(spec); + } + } + + public void removeDefault(AiToolSpec spec) { + defaultTools.remove(spec); + } + + /** + * Returns tools registered for a specific tag, merged with the default pool (tools with no tags). + */ + public Set getToolsByTag(String tag) { + Set result; + synchronized (defaultTools) { + result = new LinkedHashSet<>(defaultTools); + } + Set tagTools = tools.get(tag); + if (tagTools != null) { + synchronized (tagTools) { + result.addAll(tagTools); + } + } + return result; + } + + /** + * Returns all tools across all tags and the default pool. + */ + public Set getAllTools() { + Set result; + synchronized (defaultTools) { + result = new LinkedHashSet<>(defaultTools); + } + for (Set tagTools : tools.values()) { + synchronized (tagTools) { + result.addAll(tagTools); + } + } + return result; + } + + public Map> getTools() { + Map> snapshot = new LinkedHashMap<>(); + for (Map.Entry> entry : tools.entrySet()) { + synchronized (entry.getValue()) { + snapshot.put(entry.getKey(), new LinkedHashSet<>(entry.getValue())); + } + } + return Collections.unmodifiableMap(snapshot); + } + + public Set getDefaultTools() { + synchronized (defaultTools) { + return new LinkedHashSet<>(defaultTools); + } + } +} diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolResult.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolResult.java new file mode 100644 index 0000000000000..55cd05c576f38 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/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.tools; + +/** + * 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/tools/AiToolSpec.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolSpec.java new file mode 100644 index 0000000000000..f31640d51caa3 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/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.tools; + +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/tools/AiToolEndpointLifecycleTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolEndpointLifecycleTest.java new file mode 100644 index 0000000000000..3c0c08ee86fd5 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolEndpointLifecycleTest.java @@ -0,0 +1,285 @@ +/* + * 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.tools; + +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") + .process(exchange -> { + AiToolArguments args + = exchange.getVariable(AiTool.TOOL_ARGUMENTS, AiToolArguments.class); + String city = args != null ? args.getString("city") : "unknown"; + exchange.getMessage().setBody("Sunny in " + 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 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/tools/AiToolExecutorTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolExecutorTest.java new file mode 100644 index 0000000000000..d13358f56dbfc --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolExecutorTest.java @@ -0,0 +1,357 @@ +/* + * 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.tools; + +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; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +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") + .process(exchange -> { + AiToolArguments args + = exchange.getVariable(AiTool.TOOL_ARGUMENTS, AiToolArguments.class); + exchange.getMessage().setBody( + "Hello " + args.getString("name") + ", age " + args.get("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(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"); + } + + @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 testArgumentsAccessibleViaVariable() { + AiToolSpec spec = findSpec("greetUser"); + Exchange exchange = new DefaultExchange(context); + + Map arguments = new HashMap<>(); + arguments.put("name", "Carol"); + arguments.put("age", 42); + + AiToolResult result = AiToolExecutor.execute(spec, arguments, exchange); + + assertThat(result) + .as("Execution should succeed") + .isInstanceOf(AiToolResult.Success.class); + + AiToolArguments args = exchange.getVariable(AiTool.TOOL_ARGUMENTS, AiToolArguments.class); + assertThat(args) + .as("AiToolArguments should be set as exchange variable") + .isNotNull(); + assertThat(args.getToolName()) + .as("Tool name") + .isEqualTo("greetUser"); + assertThat(args.getString("name")) + .as("String argument") + .isEqualTo("Carol"); + assertThat(args.getInteger("age")) + .as("Integer argument") + .isEqualTo(42); + assertThat(args.has("name")) + .as("has() for existing argument") + .isTrue(); + assertThat(args.has("missing")) + .as("has() for missing argument") + .isFalse(); + } + + @Test + public void testArgumentsTypedGettersReturnNullOnBadInput() { + AiToolArguments args = new AiToolArguments("test", Map.of("bad", "not_a_number")); + + assertThat(args.getInteger("bad")) + .as("getInteger on non-numeric string should return null") + .isNull(); + assertThat(args.getDouble("bad")) + .as("getDouble on non-numeric string should return null") + .isNull(); + assertThat(args.getBoolean("bad")) + .as("getBoolean on non-boolean string should return null") + .isNull(); + assertThat(args.getInteger("missing")) + .as("getInteger on missing key should return null") + .isNull(); + } + + @Test + public void testArgumentsGetBooleanVariants() { + Map params = new HashMap<>(); + params.put("boolTrue", Boolean.TRUE); + params.put("boolFalse", Boolean.FALSE); + params.put("strTrue", "true"); + params.put("strTrueUpper", "TRUE"); + params.put("strFalse", "false"); + params.put("strFalseMixed", "False"); + params.put("strGarbage", "yes"); + + AiToolArguments args = new AiToolArguments("test", params); + + assertThat(args.getBoolean("boolTrue")).isTrue(); + assertThat(args.getBoolean("boolFalse")).isFalse(); + assertThat(args.getBoolean("strTrue")).isTrue(); + assertThat(args.getBoolean("strTrueUpper")).isTrue(); + assertThat(args.getBoolean("strFalse")).isFalse(); + assertThat(args.getBoolean("strFalseMixed")).isFalse(); + assertThat(args.getBoolean("strGarbage")) + .as("non-boolean string should return null, not false") + .isNull(); + assertThat(args.getBoolean("missing")) + .as("missing key should return null") + .isNull(); + } + + @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 testArgumentsParametersAreImmutable() { + Map params = new HashMap<>(); + params.put("key", "value"); + AiToolArguments args = new AiToolArguments("test", params); + + assertThat(args.getParameters()).containsEntry("key", "value"); + assertThatThrownBy(() -> args.getParameters().put("new", "entry")) + .as("getParameters() should return an unmodifiable map") + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + public void testArgumentsGetDoubleWithNumericInput() { + Map params = new HashMap<>(); + params.put("pi", 3.14); + params.put("count", 42); + params.put("strNum", "2.718"); + AiToolArguments args = new AiToolArguments("test", params); + + assertThat(args.getDouble("pi")) + .as("Double value should be returned directly") + .isEqualTo(3.14); + assertThat(args.getDouble("count")) + .as("Integer value should be converted to Double") + .isEqualTo(42.0); + assertThat(args.getDouble("strNum")) + .as("Numeric string should be parsed to Double") + .isEqualTo(2.718); + } + + @Test + public void testArgumentsGetStringWithNonStringValue() { + Map params = new HashMap<>(); + params.put("number", 42); + params.put("bool", true); + AiToolArguments args = new AiToolArguments("test", params); + + assertThat(args.getString("number")) + .as("Integer should be converted via toString()") + .isEqualTo("42"); + assertThat(args.getString("bool")) + .as("Boolean should be converted via toString()") + .isEqualTo("true"); + assertThat(args.getString("missing")) + .as("Missing key should return null") + .isNull(); + } + + 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/tools/AiToolParameterHelperTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolParameterHelperTest.java new file mode 100644 index 0000000000000..8d26ea2240d3c --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/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.tools; + +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/tools/AiToolRegistryTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolRegistryTest.java new file mode 100644 index 0000000000000..61ee55aceb071 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolRegistryTest.java @@ -0,0 +1,231 @@ +/* + * 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.tools; + +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; + +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 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..4a24c1821b7b9 --- /dev/null +++ b/design/aiTool.adoc @@ -0,0 +1,543 @@ +--- +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, framework-agnostic tool abstraction (`camel-ai-tool`) that all AI +components discover tools from. One way to define tools, regardless of which AI framework +you choose. No more duplication. + +== 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 yet Camel routes as tools. + +A tool defined for one framework cannot be used by another without re-registering it, +bug fixes must be applied in multiple places, and new AI components would need to +re-implement the same pattern from scratch. + +== 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, wraps them in an `AiToolArguments` variable, + 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 │ │ variable → │ │ +│ │ 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.tools`). 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 `ConcurrentHashMap` with synchronized value sets. + +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 (warn about undeclared, check required) +3. Wrap arguments in an `AiToolArguments` object and set it as an exchange variable +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 warned + about but not rejected, 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` + +=== 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 is the big one. It gives `camel-openai` Camel route tool support +for the first time. + +=== 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/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..3e793d19d9c51 --- /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/tools/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..f851795d0597a --- /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.tools.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.tools.AiToolConfiguration</code> type. + * + * Group: consumer + * + * @param configuration the value to set + * @return the dsl builder + */ + default AiToolComponentBuilder configuration(org.apache.camel.component.ai.tools.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.tools.AiToolConfiguration getOrCreateConfiguration(AiToolComponent component) { + if (component.getConfiguration() == null) { + component.setConfiguration(new org.apache.camel.component.ai.tools.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.tools.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..a49140617bc8a 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.tools.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"), From e54aee49816943d578d1797cba9d00bef7707fcd Mon Sep 17 00:00:00 2001 From: Zineb Bendhiba Date: Fri, 10 Jul 2026 10:11:42 +0200 Subject: [PATCH 2/5] Address PR review comments --- .../camel/catalog/components/ai-tool.json | 16 +- .../AiToolComponentConfigurer.java | 10 +- .../AiToolConfigurationConfigurer.java | 8 +- .../AiToolEndpointConfigurer.java | 2 +- .../AiToolEndpointUriFactory.java | 2 +- .../component/ai/{tools => tool}/ai-tool.json | 16 +- .../org/apache/camel/component/ai-tool | 2 +- .../apache/camel/configurer/ai-tool-component | 2 +- .../apache/camel/configurer/ai-tool-endpoint | 2 +- ...amel.component.ai.tool.AiToolConfiguration | 2 + ...mel.component.ai.tools.AiToolConfiguration | 2 - .../apache/camel/urifactory/ai-tool-endpoint | 2 +- .../src/main/docs/ai-tool-component.adoc | 44 +-- .../component/ai/{tools => tool}/AiTool.java | 4 +- .../ai/{tools => tool}/AiToolArguments.java | 2 +- .../ai/{tools => tool}/AiToolComponent.java | 11 +- .../{tools => tool}/AiToolConfiguration.java | 3 +- .../ai/{tools => tool}/AiToolConsumer.java | 71 ++-- .../ai/{tools => tool}/AiToolEndpoint.java | 4 +- .../ai/{tools => tool}/AiToolExecutor.java | 71 ++-- .../AiToolParameterHelper.java | 6 +- .../ai/{tools => tool}/AiToolRegistry.java | 125 +++---- .../ai/{tools => tool}/AiToolResult.java | 2 +- .../ai/{tools => tool}/AiToolSpec.java | 2 +- .../AiToolEndpointLifecycleTest.java | 9 +- .../{tools => tool}/AiToolExecutorTest.java | 60 +++- .../ai/tool/AiToolIntegrationTest.java | 313 ++++++++++++++++++ .../AiToolParameterHelperTest.java | 2 +- .../{tools => tool}/AiToolRegistryTest.java | 65 +++- design/aiTool.adoc | 43 +-- .../modules/ROOT/examples/json/ai-tool.json | 2 +- .../dsl/AiToolComponentBuilderFactory.java | 12 +- ...el-component-known-dependencies.properties | 2 +- 33 files changed, 694 insertions(+), 225 deletions(-) rename components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/{tools => tool}/AiToolComponentConfigurer.java (91%) rename components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/{tools => tool}/AiToolConfigurationConfigurer.java (86%) rename components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/{tools => tool}/AiToolEndpointConfigurer.java (98%) rename components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/{tools => tool}/AiToolEndpointUriFactory.java (98%) rename components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/{tools => tool}/ai-tool.json (77%) create mode 100644 components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.ai.tool.AiToolConfiguration delete mode 100644 components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.ai.tools.AiToolConfiguration rename components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/{tools => tool}/AiTool.java (90%) rename components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/{tools => tool}/AiToolArguments.java (98%) rename components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/{tools => tool}/AiToolComponent.java (88%) rename components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/{tools => tool}/AiToolConfiguration.java (97%) rename components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/{tools => tool}/AiToolConsumer.java (70%) rename components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/{tools => tool}/AiToolEndpoint.java (96%) rename components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/{tools => tool}/AiToolExecutor.java (70%) rename components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/{tools => tool}/AiToolParameterHelper.java (97%) rename components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/{tools => tool}/AiToolRegistry.java (59%) rename components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/{tools => tool}/AiToolResult.java (98%) rename components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/{tools => tool}/AiToolSpec.java (98%) rename components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/{tools => tool}/AiToolEndpointLifecycleTest.java (95%) rename components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/{tools => tool}/AiToolExecutorTest.java (86%) create mode 100644 components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolIntegrationTest.java rename components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/{tools => tool}/AiToolParameterHelperTest.java (99%) rename components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/{tools => tool}/AiToolRegistryTest.java (76%) 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 index 27638cc107998..f0fd90ba449e6 100644 --- 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 @@ -7,7 +7,7 @@ "deprecated": false, "firstVersion": "4.22.0", "label": "ai", - "javaType": "org.apache.camel.component.ai.tools.AiToolComponent", + "javaType": "org.apache.camel.component.ai.tool.AiToolComponent", "supportLevel": "Preview", "groupId": "org.apache.camel", "artifactId": "camel-ai-tool", @@ -25,17 +25,17 @@ }, "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.tools.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.tools.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.tools.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": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tools.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." }, + "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.tools.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.tools.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": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tools.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." }, + "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/java/org/apache/camel/component/ai/tools/AiToolComponentConfigurer.java b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolComponentConfigurer.java similarity index 91% rename from components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolComponentConfigurer.java rename to components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolComponentConfigurer.java index c1a88a372a702..5eb0aeb43c848 100644 --- a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolComponentConfigurer.java +++ b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolComponentConfigurer.java @@ -1,5 +1,5 @@ /* Generated by camel build tools - do NOT edit this file! */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; import javax.annotation.processing.Generated; import java.util.Map; @@ -19,9 +19,9 @@ @SuppressWarnings("unchecked") public class AiToolComponentConfigurer extends PropertyConfigurerSupport implements GeneratedPropertyConfigurer, PropertyConfigurerGetter { - private org.apache.camel.component.ai.tools.AiToolConfiguration getOrCreateConfiguration(AiToolComponent target) { + private org.apache.camel.component.ai.tool.AiToolConfiguration getOrCreateConfiguration(AiToolComponent target) { if (target.getConfiguration() == null) { - target.setConfiguration(new org.apache.camel.component.ai.tools.AiToolConfiguration()); + target.setConfiguration(new org.apache.camel.component.ai.tool.AiToolConfiguration()); } return target.getConfiguration(); } @@ -34,7 +34,7 @@ public boolean configure(CamelContext camelContext, Object obj, String name, Obj 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.tools.AiToolConfiguration.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; @@ -49,7 +49,7 @@ public Class getOptionType(String name, boolean ignoreCase) { case "autowiredEnabled": return boolean.class; case "bridgeerrorhandler": case "bridgeErrorHandler": return boolean.class; - case "configuration": return org.apache.camel.component.ai.tools.AiToolConfiguration.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; diff --git a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolConfigurationConfigurer.java b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolConfigurationConfigurer.java similarity index 86% rename from components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolConfigurationConfigurer.java rename to components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolConfigurationConfigurer.java index 11b3197e9bc3e..5639cfc7474cb 100644 --- a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolConfigurationConfigurer.java +++ b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolConfigurationConfigurer.java @@ -1,5 +1,5 @@ /* Generated by camel build tools - do NOT edit this file! */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; import javax.annotation.processing.Generated; import java.util.Map; @@ -10,7 +10,7 @@ import org.apache.camel.spi.ConfigurerStrategy; import org.apache.camel.spi.GeneratedPropertyConfigurer; import org.apache.camel.util.CaseInsensitiveMap; -import org.apache.camel.component.ai.tools.AiToolConfiguration; +import org.apache.camel.component.ai.tool.AiToolConfiguration; /** * Generated by camel build tools - do NOT edit this file! @@ -21,7 +21,7 @@ public class AiToolConfigurationConfigurer extends org.apache.camel.support.comp @Override public boolean configure(CamelContext camelContext, Object obj, String name, Object value, boolean ignoreCase) { - org.apache.camel.component.ai.tools.AiToolConfiguration target = (org.apache.camel.component.ai.tools.AiToolConfiguration) obj; + 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; @@ -42,7 +42,7 @@ public Class getOptionType(String name, boolean ignoreCase) { @Override public Object getOptionValue(Object obj, String name, boolean ignoreCase) { - org.apache.camel.component.ai.tools.AiToolConfiguration target = (org.apache.camel.component.ai.tools.AiToolConfiguration) obj; + 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(); diff --git a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolEndpointConfigurer.java b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointConfigurer.java similarity index 98% rename from components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolEndpointConfigurer.java rename to components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointConfigurer.java index d869ffec43e89..e799e69354da1 100644 --- a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolEndpointConfigurer.java +++ b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointConfigurer.java @@ -1,5 +1,5 @@ /* Generated by camel build tools - do NOT edit this file! */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; import javax.annotation.processing.Generated; import java.util.Map; diff --git a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolEndpointUriFactory.java b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointUriFactory.java similarity index 98% rename from components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolEndpointUriFactory.java rename to components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointUriFactory.java index 88e7ecd66999e..3bdaa5000b8c4 100644 --- a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tools/AiToolEndpointUriFactory.java +++ b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointUriFactory.java @@ -1,5 +1,5 @@ /* Generated by camel build tools - do NOT edit this file! */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; import javax.annotation.processing.Generated; import java.net.URISyntaxException; diff --git a/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tools/ai-tool.json b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tool/ai-tool.json similarity index 77% rename from components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tools/ai-tool.json rename to components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tool/ai-tool.json index 27638cc107998..f0fd90ba449e6 100644 --- a/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tools/ai-tool.json +++ b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tool/ai-tool.json @@ -7,7 +7,7 @@ "deprecated": false, "firstVersion": "4.22.0", "label": "ai", - "javaType": "org.apache.camel.component.ai.tools.AiToolComponent", + "javaType": "org.apache.camel.component.ai.tool.AiToolComponent", "supportLevel": "Preview", "groupId": "org.apache.camel", "artifactId": "camel-ai-tool", @@ -25,17 +25,17 @@ }, "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.tools.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.tools.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.tools.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": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tools.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." }, + "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.tools.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.tools.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": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tools.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." }, + "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/ai-tool b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/component/ai-tool index 0ec753438a8a4..56073f9900c5b 100644 --- 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 @@ -1,2 +1,2 @@ # Generated by camel build tools - do NOT edit this file! -class=org.apache.camel.component.ai.tools.AiToolComponent +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 index de6cdf5765971..e0c10f1d87ef6 100644 --- 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 @@ -1,2 +1,2 @@ # Generated by camel build tools - do NOT edit this file! -class=org.apache.camel.component.ai.tools.AiToolComponentConfigurer +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 index 1f1f4e2e88e23..adf891892a7b6 100644 --- 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 @@ -1,2 +1,2 @@ # Generated by camel build tools - do NOT edit this file! -class=org.apache.camel.component.ai.tools.AiToolEndpointConfigurer +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/configurer/org.apache.camel.component.ai.tools.AiToolConfiguration b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.ai.tools.AiToolConfiguration deleted file mode 100644 index 82fcec9e39192..0000000000000 --- a/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.ai.tools.AiToolConfiguration +++ /dev/null @@ -1,2 +0,0 @@ -# Generated by camel build tools - do NOT edit this file! -class=org.apache.camel.component.ai.tools.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 index c2ba1e31f34ee..ebf1720ccff89 100644 --- 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 @@ -1,2 +1,2 @@ # Generated by camel build tools - do NOT edit this file! -class=org.apache.camel.component.ai.tools.AiToolEndpointUriFactory +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 index f574d1d2f577c..e478e135d0972 100644 --- a/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc +++ b/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc @@ -14,10 +14,9 @@ *{component-header}* -The AI Tool component provides a framework-agnostic way to expose Camel routes as tools that AI models can call. -Tools registered via this component are stored in a shared `AiToolRegistry` and can be discovered by any AI producer -component (such as xref:langchain4j-agent-component.adoc[LangChain4j Agent] or xref:spring-ai-chat-component.adoc[Spring AI Chat]) -using tag-based filtering. +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: @@ -48,9 +47,6 @@ include::partial$component-endpoint-headers.adoc[] == Usage -Define a tool by creating a consumer route with the `ai-tool:` scheme. -The route body processes tool invocations and returns results to the AI model. - === Basic Tool Definition [tabs] @@ -151,7 +147,7 @@ YAML:: === Tag-Based Discovery -Tags group tools so that AI producers can select relevant subsets: +Tags group tools so that AI producers can select relevant subsets. [tabs] ==== @@ -164,13 +160,9 @@ from("ai-tool:weather?tags=weather,external-api&description=Get weather for a ci "¶meter.city=string¶meter.city.description=The city name") .to("bean:weatherService"); -from("ai-tool:queryUser?tags=users&description=Query database by user ID" + +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("sql:SELECT name FROM users WHERE id = :#id"); - -// LangChain4j agent uses only weather-tagged tools -from("direct:chat") - .to("langchain4j-agent:assistant?agent=#myAgent&tags=weather"); + .to("bean:userService?method=findById(${header.id})"); ---- XML:: @@ -184,14 +176,8 @@ XML:: - - - - - - - - + + ---- @@ -214,24 +200,16 @@ YAML:: - route: from: - uri: ai-tool:queryUser + uri: ai-tool:lookupUser parameters: tags: users - description: "Query database by user ID" + description: "Look up a user by ID" parameter.id: integer parameter.id.description: "The user ID" parameter.id.required: true steps: - to: - uri: "sql:SELECT name FROM users WHERE id = :#id" - -# LangChain4j agent uses only weather-tagged tools -- route: - from: - uri: direct:chat - steps: - - to: - uri: "langchain4j-agent:assistant?agent=#myAgent&tags=weather" + uri: "bean:userService?method=findById(${header.id})" ---- ==== diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiTool.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiTool.java similarity index 90% rename from components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiTool.java rename to components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiTool.java index 13a71a24d6d75..dc7263ab12d36 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiTool.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiTool.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; /** * Constants for the AI Tool component. @@ -28,7 +28,7 @@ public final class AiTool { /** * Exchange variable name under which {@link AiToolArguments} is set by the executor. */ - public static final String TOOL_ARGUMENTS = "AiToolArguments"; + public static final String TOOL_ARGUMENTS = "CamelAiToolArguments"; private AiTool() { } diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolArguments.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolArguments.java similarity index 98% rename from components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolArguments.java rename to components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolArguments.java index 84d2c7f14a6a3..d6f0af2a620ed 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolArguments.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolArguments.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; import java.util.Collections; import java.util.Map; diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolComponent.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolComponent.java similarity index 88% rename from components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolComponent.java rename to components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolComponent.java index 701b550002cf2..da179d5f03414 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolComponent.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolComponent.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; import java.util.Map; import java.util.stream.Collectors; @@ -26,9 +26,8 @@ import org.apache.camel.support.DefaultComponent; import org.apache.camel.util.ObjectHelper; import org.apache.camel.util.PropertiesHelper; -import org.apache.camel.util.StringHelper; -import static org.apache.camel.component.ai.tools.AiTool.SCHEME; +import static org.apache.camel.component.ai.tool.AiTool.SCHEME; /** * Camel component that registers routes as LLM-callable tools in the shared {@link AiToolRegistry}. @@ -56,8 +55,12 @@ protected Endpoint createEndpoint(String uri, String remaining, Map?description="); } + if (remaining.contains("/")) { + throw new IllegalArgumentException( + "Tool name must not contain '/': ai-tool:?description="); + } - final String toolName = StringHelper.before(remaining, "/", remaining); + final String toolName = remaining; AiToolConfiguration config = this.configuration.copy(); diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolConfiguration.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConfiguration.java similarity index 97% rename from components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolConfiguration.java rename to components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConfiguration.java index 24b14b4507508..c747389518088 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolConfiguration.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConfiguration.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; import java.util.HashMap; import java.util.Map; @@ -34,6 +34,7 @@ @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.") diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolConsumer.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConsumer.java similarity index 70% rename from components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolConsumer.java rename to components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConsumer.java index 5c785eaad8e72..354741e1bb141 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolConsumer.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConsumer.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; import java.util.Map; @@ -61,40 +61,71 @@ protected void doStart() throws Exception { registeredSpec = new AiToolSpec( toolName, configuration.getDescription(), parameterDefs, jsonSchema, this); - AiToolRegistry registry = AiToolRegistry.getOrCreate(getEndpoint().getCamelContext()); String tags = configuration.getTags(); - if (tags != null && !tags.isBlank()) { - registeredTags = AiToolParameterHelper.splitTags(tags); + String[] parsedTags = (tags != null && !tags.isBlank()) + ? AiToolParameterHelper.splitTags(tags) + : null; + if (parsedTags != null && parsedTags.length > 0) { + registeredTags = parsedTags; registeredInDefaultPool = false; - for (String tag : registeredTags) { - LOG.debug("Registering tool '{}' with tag '{}'", toolName, tag); - registry.put(tag, registeredSpec); - } } else { registeredTags = null; registeredInDefaultPool = true; - LOG.debug("Registering tool '{}' in default pool (no tags)", toolName); - registry.putDefault(registeredSpec); + } + + 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) { - 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); - } + 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/tools/AiToolEndpoint.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolEndpoint.java similarity index 96% rename from components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolEndpoint.java rename to components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolEndpoint.java index f6d295ca45e32..99ab8f673b500 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolEndpoint.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolEndpoint.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; import org.apache.camel.Category; import org.apache.camel.Consumer; @@ -26,7 +26,7 @@ import org.apache.camel.spi.UriPath; import org.apache.camel.support.DefaultEndpoint; -import static org.apache.camel.component.ai.tools.AiTool.SCHEME; +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 diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolExecutor.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java similarity index 70% rename from components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolExecutor.java rename to components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java index 946f4a43cefa0..6f40d483cd3f9 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolExecutor.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java @@ -14,9 +14,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; import java.util.HashMap; +import java.util.Locale; import java.util.Map; import java.util.Set; @@ -53,10 +54,13 @@ 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 warned about but not - * rejected, required arguments are checked) and then wrapped in an {@link AiToolArguments} object set as an - * exchange variable under {@link AiTool#TOOL_ARGUMENTS}. This avoids polluting the exchange header namespace and - * eliminates any risk of colliding with internal Camel headers. + * 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). Arguments are also wrapped in an + * {@link AiToolArguments} object set as an exchange variable under {@link AiTool#TOOL_ARGUMENTS} for programmatic + * access. *

* 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. @@ -90,18 +94,23 @@ public static AiToolResult execute(AiToolSpec spec, Map argument LOG.debug("Executing Camel route tool: '{}'", toolName); - // Warn about undeclared arguments but do not reject them -- LLMs frequently - // hallucinate extra parameters and rejecting them would break valid tool calls. - if (arguments != null && !arguments.isEmpty() && !spec.getParameterDefs().isEmpty()) { + // 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(); - for (String name : arguments.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; ignoring it", + + "that is not declared in the tool specification; filtering it out", name, toolName); + return true; } - } + return false; + }); } for (Map.Entry entry : spec.getParameterDefs().entrySet()) { @@ -115,29 +124,41 @@ public static AiToolResult execute(AiToolSpec spec, Map argument return new AiToolResult.ArgumentError(cause.getMessage(), cause); } } - - // Defensive copy so callers cannot mutate arguments during route execution - Map argsCopy = arguments != null ? new HashMap<>(arguments) : Map.of(); exchange.setVariable(AiTool.TOOL_ARGUMENTS, new AiToolArguments(toolName, argsCopy)); + // 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.ENGLISH); + 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); } - - 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"); } } diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolParameterHelper.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolParameterHelper.java similarity index 97% rename from components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolParameterHelper.java rename to components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolParameterHelper.java index c56557a81c6c0..95732b14d1de6 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolParameterHelper.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolParameterHelper.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; import java.util.ArrayList; import java.util.Arrays; @@ -50,7 +50,9 @@ public static String[] splitTags(String tagList) { if (tagList == null || tagList.isBlank()) { return new String[0]; } - return tagList.trim().split("\\s*,\\s*"); + return Arrays.stream(tagList.trim().split("\\s*,\\s*")) + .filter(s -> !s.isEmpty()) + .toArray(String[]::new); } /** diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolRegistry.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistry.java similarity index 59% rename from components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolRegistry.java rename to components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistry.java index 2fdb6d3de6454..7008dd3c8ec28 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolRegistry.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistry.java @@ -14,18 +14,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; -import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; import org.apache.camel.CamelContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * CamelContext-scoped registry mapping tags to {@link AiToolSpec} instances. AI components (LangChain4j, Spring AI) @@ -41,14 +39,15 @@ */ public final class AiToolRegistry { - private static final Logger LOG = LoggerFactory.getLogger(AiToolRegistry.class); + private static final ReentrantLock FACTORY_LOCK = new ReentrantLock(); + private final ReentrantLock lock = new ReentrantLock(); private final Map> tools; private final Set defaultTools; AiToolRegistry() { tools = new ConcurrentHashMap<>(); - defaultTools = Collections.synchronizedSet(new LinkedHashSet<>()); + defaultTools = new LinkedHashSet<>(); } /** @@ -56,7 +55,8 @@ public final class AiToolRegistry { * plugin if it does not yet exist. */ public static AiToolRegistry getOrCreate(CamelContext context) { - synchronized (context) { + FACTORY_LOCK.lock(); + try { AiToolRegistry registry = context.getCamelContextExtension() .getContextPlugin(AiToolRegistry.class); if (registry == null) { @@ -65,107 +65,120 @@ public static AiToolRegistry getOrCreate(CamelContext context) { .addContextPlugin(AiToolRegistry.class, registry); } return registry; + } finally { + FACTORY_LOCK.unlock(); } } public void put(String tag, AiToolSpec spec) { - tools.compute(tag, (k, set) -> { - if (set == null) { - set = Collections.synchronizedSet(new LinkedHashSet<>()); - } - synchronized (set) { - for (AiToolSpec existing : set) { - if (existing.getName().equals(spec.getName()) && existing != spec) { - LOG.warn("Duplicate toolName '{}' under tag '{}' -- the LLM adapter will see both " - + "and may not be able to distinguish them", - spec.getName(), tag); - break; - } + 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); } - return set; - }); + set.add(spec); + } finally { + lock.unlock(); + } } public void remove(String tag, AiToolSpec spec) { - tools.compute(tag, (k, set) -> { + lock.lock(); + try { + Set set = tools.get(tag); if (set != null) { - synchronized (set) { - set.remove(spec); - if (set.isEmpty()) { - return null; - } + set.remove(spec); + if (set.isEmpty()) { + tools.remove(tag); } } - return set; - }); + } finally { + lock.unlock(); + } } public void putDefault(AiToolSpec spec) { - synchronized (defaultTools) { + lock.lock(); + try { for (AiToolSpec existing : defaultTools) { if (existing.getName().equals(spec.getName()) && existing != spec) { - LOG.warn("Duplicate toolName '{}' in the default pool -- the LLM adapter will see both " - + "and may not be able to distinguish them", - spec.getName()); - break; + 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) { - defaultTools.remove(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) { - Set result; - synchronized (defaultTools) { - result = new LinkedHashSet<>(defaultTools); - } - Set tagTools = tools.get(tag); - if (tagTools != null) { - synchronized (tagTools) { + lock.lock(); + try { + Set result = new LinkedHashSet<>(defaultTools); + Set tagTools = tools.get(tag); + if (tagTools != null) { result.addAll(tagTools); } + return result; + } finally { + lock.unlock(); } - return result; } /** * Returns all tools across all tags and the default pool. */ public Set getAllTools() { - Set result; - synchronized (defaultTools) { - result = new LinkedHashSet<>(defaultTools); - } - for (Set tagTools : tools.values()) { - synchronized (tagTools) { + lock.lock(); + try { + Set result = new LinkedHashSet<>(defaultTools); + for (Set tagTools : tools.values()) { result.addAll(tagTools); } + return result; + } finally { + lock.unlock(); } - return result; } public Map> getTools() { - Map> snapshot = new LinkedHashMap<>(); - for (Map.Entry> entry : tools.entrySet()) { - synchronized (entry.getValue()) { + 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(); } - return Collections.unmodifiableMap(snapshot); } public Set getDefaultTools() { - synchronized (defaultTools) { + 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/tools/AiToolResult.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolResult.java similarity index 98% rename from components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolResult.java rename to components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolResult.java index 55cd05c576f38..0eb750db9dfb0 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolResult.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolResult.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; /** * Result of a tool execution by {@link AiToolExecutor}. Classifies the outcome without deciding error handling policy. diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolSpec.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolSpec.java similarity index 98% rename from components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolSpec.java rename to components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolSpec.java index f31640d51caa3..dbecd4f7d41c4 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tools/AiToolSpec.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolSpec.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; import java.util.Collections; import java.util.Map; diff --git a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolEndpointLifecycleTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolEndpointLifecycleTest.java similarity index 95% rename from components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolEndpointLifecycleTest.java rename to components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolEndpointLifecycleTest.java index 3c0c08ee86fd5..ca66e7403942c 100644 --- a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolEndpointLifecycleTest.java +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolEndpointLifecycleTest.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; import java.util.Set; @@ -39,12 +39,7 @@ public void configure() { + "¶meter.city.required=true" + "¶meter.unit=string" + "¶meter.unit.enum=celsius,fahrenheit") - .process(exchange -> { - AiToolArguments args - = exchange.getVariable(AiTool.TOOL_ARGUMENTS, AiToolArguments.class); - String city = args != null ? args.getString("city") : "unknown"; - exchange.getMessage().setBody("Sunny in " + city); - }); + .setBody(simple("Sunny in ${header.city}")); } }; } diff --git a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolExecutorTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolExecutorTest.java similarity index 86% rename from components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolExecutorTest.java rename to components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolExecutorTest.java index d13358f56dbfc..9740f10b063e0 100644 --- a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolExecutorTest.java +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolExecutorTest.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; import java.util.HashMap; import java.util.Map; @@ -43,12 +43,7 @@ public void configure() { + "¶meter.name.required=true" + "¶meter.age=integer" + "¶meter.age.description=The user age") - .process(exchange -> { - AiToolArguments args - = exchange.getVariable(AiTool.TOOL_ARGUMENTS, AiToolArguments.class); - exchange.getMessage().setBody( - "Hello " + args.getString("name") + ", age " + args.get("age")); - }); + .setBody(simple("Hello ${header.name}, age ${header.age}")); from("ai-tool:noParams" + "?tags=test" @@ -58,7 +53,7 @@ public void configure() { from("ai-tool:nullBodyTool" + "?tags=test" + "&description=A tool that returns null body") - .setBody(constant(null)); + .setBody(constant((Object) null)); from("ai-tool:failingTool" + "?tags=test" @@ -106,6 +101,9 @@ public void testExecuteIgnoresUndeclaredArguments() { 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 @@ -299,6 +297,52 @@ public void testExecuteReturnsNoResultForNullBody() { .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"); + } + @Test public void testArgumentsParametersAreImmutable() { Map params = new HashMap<>(); 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/tools/AiToolParameterHelperTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolParameterHelperTest.java similarity index 99% rename from components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolParameterHelperTest.java rename to components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolParameterHelperTest.java index 8d26ea2240d3c..09b1c9144b00d 100644 --- a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolParameterHelperTest.java +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolParameterHelperTest.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; import java.util.LinkedHashMap; import java.util.Map; diff --git a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolRegistryTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryTest.java similarity index 76% rename from components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolRegistryTest.java rename to components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryTest.java index 61ee55aceb071..e6993e82d5f23 100644 --- a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tools/AiToolRegistryTest.java +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryTest.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.camel.component.ai.tools; +package org.apache.camel.component.ai.tool; import java.util.ArrayList; import java.util.List; @@ -30,6 +30,7 @@ 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 { @@ -212,6 +213,68 @@ public void testConcurrentPutRemoveAndGet() throws Exception { .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); diff --git a/design/aiTool.adoc b/design/aiTool.adoc index 4a24c1821b7b9..2983bccc1b425 100644 --- a/design/aiTool.adoc +++ b/design/aiTool.adoc @@ -19,9 +19,8 @@ superseded-by: [] 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, framework-agnostic tool abstraction (`camel-ai-tool`) that all AI -components discover tools from. One way to define tools, regardless of which AI framework -you choose. No more duplication. +proposes a shared tool abstraction (`camel-ai-tool`) that all AI components discover +tools from. == Motivation @@ -32,11 +31,10 @@ same behavior and workflow when implementing tools. Today that is not the case: 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 yet Camel routes as tools. +* **`camel-openai`** does not support Camel routes as tools. A tool defined for one framework cannot be used by another without re-registering it, -bug fixes must be applied in multiple places, and new AI components would need to -re-implement the same pattern from scratch. +and bug fixes must be applied in multiple places. == Goals @@ -50,8 +48,10 @@ re-implement the same pattern from scratch. 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, wraps them in an `AiToolArguments` variable, - invokes the route, and returns the result. Framework adapters only need to parse their + arguments against declared parameters, sets them as individual exchange headers (filtering + out `Camel*` / `org.apache.camel.*` prefixed names to prevent header-namespace injection), + and also wraps them in an `AiToolArguments` variable for programmatic access. It then + 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, @@ -165,7 +165,7 @@ registered in the LangChain4j cache is invisible to Spring AI and vice versa. === Module: `camel-ai-tool` Located at `components/camel-ai/camel-ai-tool` (Java package: -`org.apache.camel.component.ai.tools`). Consumer-only component with no AI +`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). @@ -201,7 +201,7 @@ CamelContext-scoped registry mapping tags to sets of `AiToolSpec` instances. Eac `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 `ConcurrentHashMap` with synchronized value sets. +Thread-safe via `ReentrantLock` (virtual-thread compatible). Two pools: @@ -221,10 +221,14 @@ Key methods: 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 (warn about undeclared, check required) -3. Wrap arguments in an `AiToolArguments` object and set it as an exchange variable -4. Invoke the route processor -5. Return the result body as a string +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. Also wrap arguments in an `AiToolArguments` object and set it as an exchange variable + for programmatic access +5. Invoke the route processor +6. 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 @@ -255,8 +259,8 @@ public sealed interface AiToolResult { * **`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 warned - about but not rejected, since LLMs frequently hallucinate extra parameters.) + 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. @@ -343,7 +347,9 @@ URI syntax: `ai-tool:toolName[?options]` Lifecycle (managed by `AiToolConsumer`): * **`doStart()`**: builds `AiToolSpec` from configuration, registers in `AiToolRegistry` -* **`doStop()`**: deregisters from `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 @@ -398,8 +404,7 @@ Converts `AiToolSpec` → `ChatCompletionFunctionTool`: * `spec.getDescription()` → `FunctionDefinition.description()` * `spec.getParametersJsonSchema()` → `FunctionDefinition.parameters()` (used directly) -This is the big one. It gives `camel-openai` Camel route tool support -for the first time. +This adds Camel route tool support to `camel-openai`. === User-facing example diff --git a/docs/components/modules/ROOT/examples/json/ai-tool.json b/docs/components/modules/ROOT/examples/json/ai-tool.json index 3e793d19d9c51..ccee5b9fabc67 120000 --- a/docs/components/modules/ROOT/examples/json/ai-tool.json +++ b/docs/components/modules/ROOT/examples/json/ai-tool.json @@ -1 +1 @@ -../../../../../../components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tools/ai-tool.json \ No newline at end of file +../../../../../../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/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 index f851795d0597a..c21d5d7193913 100644 --- 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 @@ -21,7 +21,7 @@ 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.tools.AiToolComponent; +import org.apache.camel.component.ai.tool.AiToolComponent; /** * Framework-agnostic consumer endpoint that registers a Camel route as an LLM @@ -84,14 +84,14 @@ default AiToolComponentBuilder bridgeErrorHandler(boolean bridgeErrorHandler) { * The component configuration. * * The option is a: - * <code>org.apache.camel.component.ai.tools.AiToolConfiguration</code> type. + * <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.tools.AiToolConfiguration configuration) { + default AiToolComponentBuilder configuration(org.apache.camel.component.ai.tool.AiToolConfiguration configuration) { doSetProperty("configuration", configuration); return this; } @@ -181,9 +181,9 @@ class AiToolComponentBuilderImpl protected AiToolComponent buildConcreteComponent() { return new AiToolComponent(); } - private org.apache.camel.component.ai.tools.AiToolConfiguration getOrCreateConfiguration(AiToolComponent component) { + private org.apache.camel.component.ai.tool.AiToolConfiguration getOrCreateConfiguration(AiToolComponent component) { if (component.getConfiguration() == null) { - component.setConfiguration(new org.apache.camel.component.ai.tools.AiToolConfiguration()); + component.setConfiguration(new org.apache.camel.component.ai.tool.AiToolConfiguration()); } return component.getConfiguration(); } @@ -194,7 +194,7 @@ protected boolean setPropertyOnComponent( Object value) { switch (name) { case "bridgeErrorHandler": ((AiToolComponent) component).setBridgeErrorHandler((boolean) value); return true; - case "configuration": ((AiToolComponent) component).setConfiguration((org.apache.camel.component.ai.tools.AiToolConfiguration) 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; 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 a49140617bc8a..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,7 +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.tools.AiToolComponent=camel:ai-tool +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 From 811e041f79eb659035191db50ee0fad9fa7b81af Mon Sep 17 00:00:00 2001 From: Zineb Bendhiba Date: Fri, 10 Jul 2026 16:07:39 +0200 Subject: [PATCH 3/5] camel-ai-tool - improve --- .../component/ai/tool/AiToolConsumer.java | 7 +- .../component/ai/tool/AiToolEndpoint.java | 4 -- .../component/ai/tool/AiToolExecutor.java | 2 +- .../component/ai/tool/AiToolRegistry.java | 4 +- .../ai/tool/AiToolEndpointLifecycleTest.java | 64 +++++++++++++++++++ 5 files changed, 73 insertions(+), 8 deletions(-) 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 index 354741e1bb141..0b07f7c258215 100644 --- 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 @@ -58,8 +58,13 @@ protected void doStart() throws Exception { ? AiToolParameterHelper.buildJsonSchemaFromDefs(parameterDefs) : null; + String desc = configuration.getDescription(); + if (desc == null || desc.isBlank()) { + desc = toolName; + } + registeredSpec = new AiToolSpec( - toolName, configuration.getDescription(), parameterDefs, jsonSchema, this); + toolName, desc, parameterDefs, jsonSchema, this); String tags = configuration.getTags(); String[] parsedTags = (tags != null && !tags.isBlank()) 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 index 99ab8f673b500..58eca91e719b9 100644 --- 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 @@ -68,10 +68,6 @@ public Producer createProducer() { @Override public Consumer createConsumer(Processor processor) throws Exception { - if (configuration.getDescription() == null || configuration.getDescription().isBlank()) { - configuration.setDescription(toolName); - } - AiToolConsumer consumer = new AiToolConsumer(this, processor); configureConsumer(consumer); return consumer; diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java index 6f40d483cd3f9..6d04db1cd9c56 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java @@ -131,7 +131,7 @@ public static AiToolResult execute(AiToolSpec spec, Map argument // header-namespace injection (CVE-2025-27636 family). for (Map.Entry entry : argsCopy.entrySet()) { String name = entry.getKey(); - String lower = name.toLowerCase(Locale.ENGLISH); + 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", 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 index 7008dd3c8ec28..c47562fd820c6 100644 --- 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 @@ -16,11 +16,11 @@ */ 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.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; import org.apache.camel.CamelContext; @@ -46,7 +46,7 @@ public final class AiToolRegistry { private final Set defaultTools; AiToolRegistry() { - tools = new ConcurrentHashMap<>(); + tools = new HashMap<>(); defaultTools = new LinkedHashSet<>(); } 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 index ca66e7403942c..762a0349f856b 100644 --- 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 @@ -256,6 +256,70 @@ public void configure() { .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() { From ff866167722aa27f1a4a83f80009758442127d51 Mon Sep 17 00:00:00 2001 From: Zineb Bendhiba Date: Fri, 10 Jul 2026 16:22:12 +0200 Subject: [PATCH 4/5] remove AiToolArguments variable from exchange --- .../camel/component/ai/tool/AiTool.java | 5 - .../component/ai/tool/AiToolArguments.java | 124 ----------------- .../component/ai/tool/AiToolExecutor.java | 6 +- .../component/ai/tool/AiToolExecutorTest.java | 131 ------------------ design/aiTool.adoc | 9 +- 5 files changed, 4 insertions(+), 271 deletions(-) delete mode 100644 components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolArguments.java 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 index dc7263ab12d36..b77053684b801 100644 --- 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 @@ -25,11 +25,6 @@ public final class AiTool { public static final String SCHEME = "ai-tool"; - /** - * Exchange variable name under which {@link AiToolArguments} is set by the executor. - */ - public static final String TOOL_ARGUMENTS = "CamelAiToolArguments"; - private AiTool() { } } diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolArguments.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolArguments.java deleted file mode 100644 index d6f0af2a620ed..0000000000000 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolArguments.java +++ /dev/null @@ -1,124 +0,0 @@ -/* - * 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; - -/** - * Typed wrapper around the tool arguments sent by the LLM. Set as an exchange variable by {@link AiToolExecutor} under - * the key {@link AiTool#TOOL_ARGUMENTS}, so route authors can retrieve it with: - * - *

{@code
- * AiToolArguments args = exchange.getVariable(AiTool.TOOL_ARGUMENTS, AiToolArguments.class);
- * String city = args.getString("city");
- * }
- * - * @since 4.22 - */ -public final class AiToolArguments { - - private final String toolName; - private final Map parameters; - - public AiToolArguments(String toolName, Map parameters) { - this.toolName = toolName; - this.parameters = parameters != null ? Collections.unmodifiableMap(parameters) : Map.of(); - } - - public String getToolName() { - return toolName; - } - - public Map getParameters() { - return parameters; - } - - public Object get(String name) { - return parameters.get(name); - } - - public String getString(String name) { - Object value = parameters.get(name); - return value != null ? value.toString() : null; - } - - /** - * Returns the argument as an Integer, or {@code null} if absent or not convertible. - */ - public Integer getInteger(String name) { - Object value = parameters.get(name); - if (value instanceof Number n) { - return n.intValue(); - } - if (value instanceof String s) { - try { - return Integer.parseInt(s); - } catch (NumberFormatException e) { - return null; - } - } - return null; - } - - /** - * Returns the argument as a Double, or {@code null} if absent or not convertible. - */ - public Double getDouble(String name) { - Object value = parameters.get(name); - if (value instanceof Number n) { - return n.doubleValue(); - } - if (value instanceof String s) { - try { - return Double.parseDouble(s); - } catch (NumberFormatException e) { - return null; - } - } - return null; - } - - /** - * Returns the argument as a Boolean, or {@code null} if absent or not convertible. - */ - public Boolean getBoolean(String name) { - Object value = parameters.get(name); - if (value instanceof Boolean b) { - return b; - } - if (value instanceof String s) { - if ("true".equalsIgnoreCase(s)) { - return Boolean.TRUE; - } - if ("false".equalsIgnoreCase(s)) { - return Boolean.FALSE; - } - return null; - } - return null; - } - - public boolean has(String name) { - return parameters.containsKey(name); - } - - @Override - public String toString() { - return "AiToolArguments{toolName=" + toolName + ", parameters=" + parameters + '}'; - } -} diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java index 6d04db1cd9c56..5b61d3cab8da1 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java @@ -58,9 +58,7 @@ private AiToolExecutor() { * 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). Arguments are also wrapped in an - * {@link AiToolArguments} object set as an exchange variable under {@link AiTool#TOOL_ARGUMENTS} for programmatic - * access. + * 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. @@ -124,8 +122,6 @@ public static AiToolResult execute(AiToolSpec spec, Map argument return new AiToolResult.ArgumentError(cause.getMessage(), cause); } } - exchange.setVariable(AiTool.TOOL_ARGUMENTS, new AiToolArguments(toolName, argsCopy)); - // 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). 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 index 9740f10b063e0..230f53defdc76 100644 --- 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 @@ -27,7 +27,6 @@ import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; public class AiToolExecutorTest extends CamelTestSupport { @@ -203,87 +202,6 @@ public void testExecuteReturnsExecutionErrorForNullProcessor() { assertThat(error.cause()).isInstanceOf(IllegalStateException.class); } - @Test - public void testArgumentsAccessibleViaVariable() { - AiToolSpec spec = findSpec("greetUser"); - Exchange exchange = new DefaultExchange(context); - - Map arguments = new HashMap<>(); - arguments.put("name", "Carol"); - arguments.put("age", 42); - - AiToolResult result = AiToolExecutor.execute(spec, arguments, exchange); - - assertThat(result) - .as("Execution should succeed") - .isInstanceOf(AiToolResult.Success.class); - - AiToolArguments args = exchange.getVariable(AiTool.TOOL_ARGUMENTS, AiToolArguments.class); - assertThat(args) - .as("AiToolArguments should be set as exchange variable") - .isNotNull(); - assertThat(args.getToolName()) - .as("Tool name") - .isEqualTo("greetUser"); - assertThat(args.getString("name")) - .as("String argument") - .isEqualTo("Carol"); - assertThat(args.getInteger("age")) - .as("Integer argument") - .isEqualTo(42); - assertThat(args.has("name")) - .as("has() for existing argument") - .isTrue(); - assertThat(args.has("missing")) - .as("has() for missing argument") - .isFalse(); - } - - @Test - public void testArgumentsTypedGettersReturnNullOnBadInput() { - AiToolArguments args = new AiToolArguments("test", Map.of("bad", "not_a_number")); - - assertThat(args.getInteger("bad")) - .as("getInteger on non-numeric string should return null") - .isNull(); - assertThat(args.getDouble("bad")) - .as("getDouble on non-numeric string should return null") - .isNull(); - assertThat(args.getBoolean("bad")) - .as("getBoolean on non-boolean string should return null") - .isNull(); - assertThat(args.getInteger("missing")) - .as("getInteger on missing key should return null") - .isNull(); - } - - @Test - public void testArgumentsGetBooleanVariants() { - Map params = new HashMap<>(); - params.put("boolTrue", Boolean.TRUE); - params.put("boolFalse", Boolean.FALSE); - params.put("strTrue", "true"); - params.put("strTrueUpper", "TRUE"); - params.put("strFalse", "false"); - params.put("strFalseMixed", "False"); - params.put("strGarbage", "yes"); - - AiToolArguments args = new AiToolArguments("test", params); - - assertThat(args.getBoolean("boolTrue")).isTrue(); - assertThat(args.getBoolean("boolFalse")).isFalse(); - assertThat(args.getBoolean("strTrue")).isTrue(); - assertThat(args.getBoolean("strTrueUpper")).isTrue(); - assertThat(args.getBoolean("strFalse")).isFalse(); - assertThat(args.getBoolean("strFalseMixed")).isFalse(); - assertThat(args.getBoolean("strGarbage")) - .as("non-boolean string should return null, not false") - .isNull(); - assertThat(args.getBoolean("missing")) - .as("missing key should return null") - .isNull(); - } - @Test public void testExecuteReturnsNoResultForNullBody() { AiToolSpec spec = findSpec("nullBodyTool"); @@ -343,55 +261,6 @@ public void testCamelPrefixedArgumentsRejectedFromHeaders() { .isEqualTo("ok"); } - @Test - public void testArgumentsParametersAreImmutable() { - Map params = new HashMap<>(); - params.put("key", "value"); - AiToolArguments args = new AiToolArguments("test", params); - - assertThat(args.getParameters()).containsEntry("key", "value"); - assertThatThrownBy(() -> args.getParameters().put("new", "entry")) - .as("getParameters() should return an unmodifiable map") - .isInstanceOf(UnsupportedOperationException.class); - } - - @Test - public void testArgumentsGetDoubleWithNumericInput() { - Map params = new HashMap<>(); - params.put("pi", 3.14); - params.put("count", 42); - params.put("strNum", "2.718"); - AiToolArguments args = new AiToolArguments("test", params); - - assertThat(args.getDouble("pi")) - .as("Double value should be returned directly") - .isEqualTo(3.14); - assertThat(args.getDouble("count")) - .as("Integer value should be converted to Double") - .isEqualTo(42.0); - assertThat(args.getDouble("strNum")) - .as("Numeric string should be parsed to Double") - .isEqualTo(2.718); - } - - @Test - public void testArgumentsGetStringWithNonStringValue() { - Map params = new HashMap<>(); - params.put("number", 42); - params.put("bool", true); - AiToolArguments args = new AiToolArguments("test", params); - - assertThat(args.getString("number")) - .as("Integer should be converted via toString()") - .isEqualTo("42"); - assertThat(args.getString("bool")) - .as("Boolean should be converted via toString()") - .isEqualTo("true"); - assertThat(args.getString("missing")) - .as("Missing key should return null") - .isNull(); - } - private AiToolSpec findSpec(String toolName) { return AiToolRegistry.getOrCreate(context).getToolsByTag("test").stream() .filter(s -> toolName.equals(s.getName())) diff --git a/design/aiTool.adoc b/design/aiTool.adoc index 2983bccc1b425..74a0fddef4a40 100644 --- a/design/aiTool.adoc +++ b/design/aiTool.adoc @@ -50,8 +50,7 @@ and bug fixes must be applied in multiple places. * 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), - and also wraps them in an `AiToolArguments` variable for programmatic access. It then - invokes the route and returns the result. Framework adapters only need to parse their + 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, @@ -225,10 +224,8 @@ Static utility class that handles the common dispatch logic: 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. Also wrap arguments in an `AiToolArguments` object and set it as an exchange variable - for programmatic access -5. Invoke the route processor -6. Return the result body as a string +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 From 1eda6daa24cce79dc5b49ac799f7dd57af8c0112 Mon Sep 17 00:00:00 2001 From: Zineb Bendhiba Date: Fri, 10 Jul 2026 16:31:18 +0200 Subject: [PATCH 5/5] update doc --- design/aiTool.adoc | 2 +- design/langchain4j-evolution.adoc | 385 ++++++++++++++++++++++++++++++ 2 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 design/langchain4j-evolution.adoc diff --git a/design/aiTool.adoc b/design/aiTool.adoc index 74a0fddef4a40..782023cbbbe5b 100644 --- a/design/aiTool.adoc +++ b/design/aiTool.adoc @@ -138,7 +138,7 @@ registered in the LangChain4j cache is invisible to Spring AI and vice versa. │ ┌──────────────┐ ┌───────────────┐ ┌───────────────┐ │ │ │ AiToolSpec │ │ AiToolRegistry│ │ AiToolExecutor│ │ │ │ (name, desc,│ │ (per-context) │ │ (validate → │ │ -│ │ params, │ │ tags → specs │ │ variable → │ │ +│ │ params, │ │ tags → specs │ │ headers → │ │ │ │ consumer) │ │ + default │ │ route → │ │ │ └──────────────┘ │ pool │ │ result) │ │ │ └───────────────┘ └───────────────┘ │ 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. +|===