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