gnodet-bot commented on code in PR #28354:
URL: https://github.com/apache/camel/pull/28354#discussion_r4360115808


##########
components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolSearchIndex.java:
##########
@@ -0,0 +1,247 @@
+/*
+ * 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.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Ranks route and remote MCP tools in one search space.
+ * <p>
+ * Callers apply producer, tag, and MCP filters before building the index. 
Only candidates marked
+ * {@link AiToolSearchCandidate#searchable()} are ranked. Duplicate names keep 
the first candidate. The index does not
+ * store request discovery state and does not invoke tools.
+ * <p>
+ * Keyword scoring matches a deterministic contains match: each distinct term 
adds 2 when it occurs in the name and 1
+ * when it occurs in the description. A tool needs a score of at least 1. 
Equal scores keep catalogue order.
+ * <p>
+ * Vector scoring is cosine similarity of embeddings of {@code name} and 
{@code description}. A missing embedder is
+ * rejected and is never treated as keyword search.
+ *
+ * @since 4.24
+ */
+public final class AiToolSearchIndex {
+
+    private final AiToolSearchStrategy strategy;
+    private final int maxResults;
+    private final AiToolSearchEmbedder embedder;
+    private final List<Entry> entries;
+
+    private AiToolSearchIndex(
+                              AiToolSearchStrategy strategy, int maxResults, 
AiToolSearchEmbedder embedder,
+                              List<Entry> entries) {
+        this.strategy = strategy;
+        this.maxResults = maxResults;
+        this.embedder = embedder;
+        this.entries = List.copyOf(entries);
+    }
+
+    /**
+     * Builds an index over an already filtered catalogue.
+     *
+     * @param  strategy   keyword or vector
+     * @param  candidates route and MCP candidates in catalogue order; may 
include directly visible tools
+     * @param  maxResults maximum number of hits, at least 1
+     * @param  embedder   required for {@link AiToolSearchStrategy#VECTOR}; 
ignored for keyword search
+     * @return            an immutable index of the searchable candidates
+     */
+    public static AiToolSearchIndex create(
+            AiToolSearchStrategy strategy, List<AiToolSearchCandidate> 
candidates, int maxResults,
+            AiToolSearchEmbedder embedder) {
+        if (strategy == null) {
+            throw new IllegalArgumentException("Tool search strategy is 
required");
+        }
+        if (maxResults < 1) {
+            throw new IllegalArgumentException("toolSearchMaxResults must be 
at least 1");
+        }
+        if (strategy == AiToolSearchStrategy.VECTOR && embedder == null) {
+            throw new IllegalArgumentException(
+                    "VECTOR tool search requires an embedding model and does 
not fall back to keyword search");
+        }
+        List<AiToolSearchCandidate> searchable = searchableInOrder(candidates);
+        List<Entry> entries = new ArrayList<>(searchable.size());
+        if (strategy == AiToolSearchStrategy.VECTOR) {
+            List<String> texts = new ArrayList<>(searchable.size());
+            for (AiToolSearchCandidate candidate : searchable) {
+                texts.add(embeddingText(candidate));
+            }
+            for (int i = 0; i < searchable.size(); i++) {
+                float[] vector = embedder.embed(texts.get(i));
+                if (vector == null || vector.length == 0) {
+                    throw new IllegalArgumentException(
+                            "Embedding model returned an empty vector for tool 
'" + searchable.get(i).name() + "'");
+                }
+                entries.add(new Entry(searchable.get(i), null, null, vector));
+            }
+        } else {
+            for (AiToolSearchCandidate candidate : searchable) {
+                entries.add(new Entry(candidate, lower(candidate.name()), 
lower(candidate.description()), null));
+            }
+        }
+        return new AiToolSearchIndex(
+                strategy, maxResults, strategy == AiToolSearchStrategy.VECTOR 
? embedder : null,
+                entries);
+    }
+
+    public AiToolSearchStrategy strategy() {
+        return strategy;
+    }
+
+    public int size() {
+        return entries.size();
+    }
+
+    /**
+     * Keyword search. Each term is split on whitespace. Blank input and 
no-match queries return an empty list.
+     */
+    public List<AiToolSearchCandidate> searchKeyword(List<String> terms) {
+        if (strategy != AiToolSearchStrategy.KEYWORD) {
+            throw new IllegalStateException("This index uses " + strategy + " 
search, not keyword search");
+        }
+        List<String> cleaned = clean(terms);
+        if (cleaned.isEmpty() || entries.isEmpty()) {
+            return List.of();
+        }
+        List<Scored> scored = new ArrayList<>();
+        for (Entry entry : entries) {
+            int score = 0;
+            for (String term : cleaned) {
+                if (entry.name.contains(term)) {
+                    score += 2;
+                }
+                if (entry.description != null && 
entry.description.contains(term)) {
+                    score += 1;
+                }
+            }
+            if (score >= 1) {
+                scored.add(new Scored(entry.candidate, score));
+            }
+        }
+        return limit(scored);
+    }
+
+    /**
+     * Vector search. The closest tools are returned, up to the configured 
limit. An empty catalogue or a blank query
+     * returns an empty list.
+     */
+    public List<AiToolSearchCandidate> searchVector(String query) {
+        if (strategy != AiToolSearchStrategy.VECTOR) {
+            throw new IllegalStateException("This index uses " + strategy + " 
search, not vector search");
+        }
+        if (embedder == null) {
+            throw new IllegalArgumentException(
+                    "VECTOR tool search requires an embedding model and does 
not fall back to keyword search");
+        }
+        if (entries.isEmpty() || query == null || query.isBlank()) {
+            return List.of();
+        }
+        float[] queryVector = embedder.embed(query);
+        if (queryVector == null || queryVector.length == 0) {
+            throw new IllegalArgumentException("Embedding model returned an 
empty vector for the search query");
+        }
+        List<Scored> scored = new ArrayList<>(entries.size());
+        for (Entry entry : entries) {
+            scored.add(new Scored(entry.candidate, cosine(queryVector, 
entry.vector)));
+        }
+        return limit(scored);
+    }
+
+    private List<AiToolSearchCandidate> limit(List<Scored> scored) {
+        scored.sort((left, right) -> Double.compare(right.score, left.score));

Review Comment:
   The class Javadoc says "Equal scores keep catalogue order", but this 
comparator has no secondary key, so ties are broken by whatever order 
`List.sort` leaves them in (implementation-defined). Add a secondary comparator 
on the original catalogue index to make the tie-breaking deterministic and 
match the documented contract.
   
   Suggestion:
   ```java
   scored.sort(Comparator.comparingDouble((Scored s) -> s.score).reversed()
       .thenComparingInt(s -> originalIndex(s.candidate)));
   ```
   
   <!-- generated by an AI agent, Hermès -->



##########
components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolSearchIndex.java:
##########
@@ -0,0 +1,247 @@
+/*
+ * 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.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Ranks route and remote MCP tools in one search space.
+ * <p>
+ * Callers apply producer, tag, and MCP filters before building the index. 
Only candidates marked
+ * {@link AiToolSearchCandidate#searchable()} are ranked. Duplicate names keep 
the first candidate. The index does not
+ * store request discovery state and does not invoke tools.
+ * <p>
+ * Keyword scoring matches a deterministic contains match: each distinct term 
adds 2 when it occurs in the name and 1
+ * when it occurs in the description. A tool needs a score of at least 1. 
Equal scores keep catalogue order.
+ * <p>
+ * Vector scoring is cosine similarity of embeddings of {@code name} and 
{@code description}. A missing embedder is
+ * rejected and is never treated as keyword search.
+ *
+ * @since 4.24
+ */
+public final class AiToolSearchIndex {
+
+    private final AiToolSearchStrategy strategy;
+    private final int maxResults;
+    private final AiToolSearchEmbedder embedder;
+    private final List<Entry> entries;
+
+    private AiToolSearchIndex(
+                              AiToolSearchStrategy strategy, int maxResults, 
AiToolSearchEmbedder embedder,
+                              List<Entry> entries) {
+        this.strategy = strategy;
+        this.maxResults = maxResults;
+        this.embedder = embedder;
+        this.entries = List.copyOf(entries);
+    }
+
+    /**
+     * Builds an index over an already filtered catalogue.
+     *
+     * @param  strategy   keyword or vector
+     * @param  candidates route and MCP candidates in catalogue order; may 
include directly visible tools
+     * @param  maxResults maximum number of hits, at least 1
+     * @param  embedder   required for {@link AiToolSearchStrategy#VECTOR}; 
ignored for keyword search
+     * @return            an immutable index of the searchable candidates
+     */
+    public static AiToolSearchIndex create(
+            AiToolSearchStrategy strategy, List<AiToolSearchCandidate> 
candidates, int maxResults,
+            AiToolSearchEmbedder embedder) {
+        if (strategy == null) {
+            throw new IllegalArgumentException("Tool search strategy is 
required");
+        }
+        if (maxResults < 1) {
+            throw new IllegalArgumentException("toolSearchMaxResults must be 
at least 1");
+        }
+        if (strategy == AiToolSearchStrategy.VECTOR && embedder == null) {
+            throw new IllegalArgumentException(
+                    "VECTOR tool search requires an embedding model and does 
not fall back to keyword search");
+        }
+        List<AiToolSearchCandidate> searchable = searchableInOrder(candidates);
+        List<Entry> entries = new ArrayList<>(searchable.size());
+        if (strategy == AiToolSearchStrategy.VECTOR) {
+            List<String> texts = new ArrayList<>(searchable.size());
+            for (AiToolSearchCandidate candidate : searchable) {
+                texts.add(embeddingText(candidate));
+            }
+            for (int i = 0; i < searchable.size(); i++) {
+                float[] vector = embedder.embed(texts.get(i));
+                if (vector == null || vector.length == 0) {
+                    throw new IllegalArgumentException(
+                            "Embedding model returned an empty vector for tool 
'" + searchable.get(i).name() + "'");
+                }
+                entries.add(new Entry(searchable.get(i), null, null, vector));
+            }
+        } else {
+            for (AiToolSearchCandidate candidate : searchable) {
+                entries.add(new Entry(candidate, lower(candidate.name()), 
lower(candidate.description()), null));
+            }
+        }
+        return new AiToolSearchIndex(
+                strategy, maxResults, strategy == AiToolSearchStrategy.VECTOR 
? embedder : null,
+                entries);
+    }
+
+    public AiToolSearchStrategy strategy() {
+        return strategy;
+    }
+
+    public int size() {
+        return entries.size();
+    }
+
+    /**
+     * Keyword search. Each term is split on whitespace. Blank input and 
no-match queries return an empty list.
+     */
+    public List<AiToolSearchCandidate> searchKeyword(List<String> terms) {
+        if (strategy != AiToolSearchStrategy.KEYWORD) {
+            throw new IllegalStateException("This index uses " + strategy + " 
search, not keyword search");
+        }
+        List<String> cleaned = clean(terms);
+        if (cleaned.isEmpty() || entries.isEmpty()) {
+            return List.of();
+        }
+        List<Scored> scored = new ArrayList<>();
+        for (Entry entry : entries) {
+            int score = 0;
+            for (String term : cleaned) {
+                if (entry.name.contains(term)) {
+                    score += 2;
+                }
+                if (entry.description != null && 
entry.description.contains(term)) {
+                    score += 1;
+                }
+            }
+            if (score >= 1) {
+                scored.add(new Scored(entry.candidate, score));
+            }
+        }
+        return limit(scored);
+    }
+
+    /**
+     * Vector search. The closest tools are returned, up to the configured 
limit. An empty catalogue or a blank query
+     * returns an empty list.
+     */
+    public List<AiToolSearchCandidate> searchVector(String query) {
+        if (strategy != AiToolSearchStrategy.VECTOR) {
+            throw new IllegalStateException("This index uses " + strategy + " 
search, not vector search");
+        }
+        if (embedder == null) {
+            throw new IllegalArgumentException(
+                    "VECTOR tool search requires an embedding model and does 
not fall back to keyword search");
+        }
+        if (entries.isEmpty() || query == null || query.isBlank()) {
+            return List.of();
+        }
+        float[] queryVector = embedder.embed(query);
+        if (queryVector == null || queryVector.length == 0) {
+            throw new IllegalArgumentException("Embedding model returned an 
empty vector for the search query");
+        }
+        List<Scored> scored = new ArrayList<>(entries.size());
+        for (Entry entry : entries) {
+            scored.add(new Scored(entry.candidate, cosine(queryVector, 
entry.vector)));

Review Comment:
   Unlike `searchKeyword()` which filters with `if (score >= 1)`, 
`searchVector()` adds every entry unconditionally. Consider filtering out 
candidates below a configurable cosine similarity threshold (e.g., defaulting 
to 0.0 to preserve current behaviour but allowing callers to raise it). At 
minimum, document the intentional absence of a threshold here.
   
   <!-- generated by an AI agent, Hermès -->



##########
components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolSearchSessions.java:
##########
@@ -0,0 +1,46 @@
+/*
+ * 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.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * Conversation-scoped {@link AiToolSearchSession}s.
+ * <p>
+ * A missing or blank key means the caller should keep a request-local session 
instead of sharing one. Distinct keys
+ * never see each other's discovered names.
+ *
+ * @since 4.24
+ */
+public final class AiToolSearchSessions {
+
+    private final ConcurrentMap<String, AiToolSearchSession> sessions = new 
ConcurrentHashMap<>();

Review Comment:
   This `ConcurrentHashMap` grows without bound. In long-running deployments 
with many distinct conversation IDs (or if callers forget to close 
conversations) this is an unbounded memory leak. Consider a size-bounded cache 
(Caffeine, Guava, or a `LinkedHashMap` with `removeEldestEntry`) or expose a 
`remove(conversationId)` method that callers can invoke when a conversation 
ends.
   
   <!-- generated by an AI agent, Hermès -->



##########
components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/CamelToolSearchStrategy.java:
##########
@@ -0,0 +1,177 @@
+/*
+ * 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.langchain4j.agent;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.langchain4j.agent.tool.ToolSpecification;
+import dev.langchain4j.invocation.InvocationContext;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import dev.langchain4j.model.embedding.EmbeddingModel;
+import dev.langchain4j.service.tool.search.ToolSearchRequest;
+import dev.langchain4j.service.tool.search.ToolSearchResult;
+import dev.langchain4j.service.tool.search.ToolSearchStrategy;
+import org.apache.camel.component.ai.tool.AiToolSearch;
+import org.apache.camel.component.ai.tool.AiToolSearchCandidate;
+import org.apache.camel.component.ai.tool.AiToolSearchEmbedder;
+import org.apache.camel.component.ai.tool.AiToolSearchIndex;
+import org.apache.camel.component.ai.tool.AiToolSearchStrategy;
+
+/**
+ * Ranks the tools LangChain4j has already combined from route and MCP 
providers.
+ * <p>
+ * LangChain4j still executes a found tool through its original executor and 
restores names found earlier from chat
+ * memory. This strategy only replaces the ranking engine. Dynamic tool 
providers registered outside Camel's composite
+ * are refreshed after search and are not part of this catalogue.
+ */
+final class CamelToolSearchStrategy implements ToolSearchStrategy {
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+    private static final TypeReference<Map<String, Object>> MAP_TYPE = new 
TypeReference<>() {
+    };
+
+    private final AiToolSearchStrategy strategy;
+    private final int maxResults;
+    private final EmbeddingModel embeddingModel;
+
+    CamelToolSearchStrategy(AiToolSearchStrategy strategy, int maxResults, 
EmbeddingModel embeddingModel) {
+        this.strategy = strategy;
+        this.maxResults = maxResults;
+        this.embeddingModel = embeddingModel;
+    }
+
+    @Override
+    public List<ToolSpecification> getToolSearchTools(InvocationContext 
invocationContext) {
+        return List.of(synthetic());
+    }
+
+    @Override
+    public ToolSearchResult search(ToolSearchRequest request) {
+        List<AiToolSearchCandidate> candidates = new ArrayList<>();
+        for (ToolSpecification specification : request.searchableTools()) {
+            if (AiToolSearch.TOOL_NAME.equals(specification.name())) {
+                throw new IllegalArgumentException(
+                        "Tool name '" + AiToolSearch.TOOL_NAME + "' is 
reserved while toolSearch=true");
+            }
+            String schema = specification.parameters() == null ? null : 
specification.parameters().toString();
+            candidates.add(new AiToolSearchCandidate(
+                    specification.name(), specification.description(), schema, 
true, specification));
+        }
+        AiToolSearchEmbedder embedder = embeddingModel == null ? null : text 
-> {
+            var response = embeddingModel.embed(text);
+            if (response == null || response.content() == null || 
response.content().vector() == null) {
+                throw new IllegalStateException("Embedding model returned no 
vector");
+            }
+            return response.content().vector();
+        };
+        AiToolSearchIndex index = AiToolSearchIndex.create(strategy, 
candidates, maxResults, embedder);
+        String arguments = request.toolExecutionRequest() == null ? null : 
request.toolExecutionRequest().arguments();
+        if (strategy == AiToolSearchStrategy.VECTOR) {
+            String query = readString(arguments, AiToolSearch.QUERY_ARGUMENT);
+            if (query == null) {
+                return new ToolSearchResult(
+                        List.of(),
+                        "Error: missing required tool argument '" + 
AiToolSearch.QUERY_ARGUMENT + "'");
+            }
+            return result(index.searchVector(query));
+        }
+        List<String> terms = readTerms(arguments);
+        if (terms == null) {
+            return new ToolSearchResult(
+                    List.of(),
+                    "Error: missing required tool argument '" + 
AiToolSearch.TERMS_ARGUMENT + "'");
+        }
+        return result(index.searchKeyword(terms));
+    }
+
+    private ToolSpecification synthetic() {
+        boolean vector = strategy == AiToolSearchStrategy.VECTOR;
+        JsonObjectSchema parameters = vector
+                ? JsonObjectSchema.builder()
+                        .addProperty(AiToolSearch.QUERY_ARGUMENT, 
JsonStringSchema.builder()
+                                .description("Natural language query 
describing the desired tool")
+                                .build())
+                        .required(AiToolSearch.QUERY_ARGUMENT)
+                        .build()
+                : JsonObjectSchema.builder()
+                        .addProperty(AiToolSearch.TERMS_ARGUMENT, 
JsonArraySchema.builder()
+                                .description("A list of individual search 
terms used to find relevant tools")
+                                .items(JsonStringSchema.builder().build())
+                                .build())
+                        .required(AiToolSearch.TERMS_ARGUMENT)
+                        .build();
+        return ToolSpecification.builder()
+                .name(AiToolSearch.TOOL_NAME)
+                .description(vector
+                        ? "Finds available tools using semantic vector search. 
Matching tools become available to call on the next turn."
+                        : "Finds available tools whose name or description 
contains the given search terms. Matching tools become available to call on the 
next turn.")
+                .parameters(parameters)
+                .build();
+    }
+
+    private static ToolSearchResult result(List<AiToolSearchCandidate> hits) {
+        List<String> names = 
hits.stream().map(AiToolSearchCandidate::name).toList();
+        return new ToolSearchResult(names, AiToolSearch.resultMessage(names));
+    }
+
+    private static List<String> readTerms(String argumentsJson) {

Review Comment:
   `readTerms`, `readString`, and `readArguments` are copy-pasted verbatim in 
`CamelToolSearchStrategy`, `OpenAIToolSearch`, and `SpringAiToolSearch`. 
Extract them to a package-private utility class (e.g., `AiToolSearchArguments`) 
in `camel-ai-tool` so any future fix applies in one place.
   
   <!-- generated by an AI agent, Hermès -->



##########
components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiToolSearch.java:
##########
@@ -0,0 +1,382 @@
+/*
+ * 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.springai.chat;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.camel.component.ai.tool.AiToolSearch;
+import org.apache.camel.component.ai.tool.AiToolSearchCandidate;
+import org.apache.camel.component.ai.tool.AiToolSearchEmbedder;
+import org.apache.camel.component.ai.tool.AiToolSearchIndex;
+import org.apache.camel.component.ai.tool.AiToolSearchSession;
+import org.apache.camel.component.ai.tool.AiToolSearchSessions;
+import org.apache.camel.component.ai.tool.AiToolSearchStrategy;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.springframework.ai.chat.client.ChatClientRequest;
+import org.springframework.ai.chat.client.advisor.ToolCallingAdvisor;
+import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
+import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.embedding.EmbeddingModel;
+import org.springframework.ai.model.tool.ToolCallingChatOptions;
+import org.springframework.ai.model.tool.ToolCallingManager;
+import org.springframework.ai.model.tool.ToolExecutionEligibilityChecker;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.definition.ToolDefinition;
+
+/**
+ * Rebuilds the tool list inside Spring AI's existing tool-calling loop.
+ * <p>
+ * The advisor does not run its own loop. {@code doBeforeCall} and {@code 
doBeforeStream} replace the immutable callback
+ * snapshot with the directly visible tools, the synthetic search tool, and 
tools already discovered for this request or
+ * conversation. Execution stays on the original callback.
+ */
+final class SpringAiToolSearch {
+
+    static final String CATALOGUE = "CamelSpringAiToolSearchCatalogue";
+    static final String SEARCHABLE = "CamelSpringAiToolSearchSearchableNames";
+    static final String CONVERSATION = "CamelSpringAiToolSearchConversationId";
+    static final String SESSION = "CamelSpringAiToolSearchSession";
+
+    private static final String KEYWORD_SCHEMA = 
"{\"type\":\"object\",\"properties\":{\"terms\":{\"type\":\"array\","
+                                                 + "\"description\":\"A list 
of individual search terms used to find relevant tools\","
+                                                 + 
"\"items\":{\"type\":\"string\"}}},\"required\":[\"terms\"]}";
+    private static final String VECTOR_SCHEMA = 
"{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\","
+                                                + "\"description\":\"Natural 
language query describing the desired tool\"}},"
+                                                + "\"required\":[\"query\"]}";
+
+    private final AiToolSearchStrategy strategy;
+    private final int maxResults;
+    private final EmbeddingModel embeddingModel;
+    private final AiToolSearchSessions sessions = new AiToolSearchSessions();
+    private final Object indexLock = new Object();
+    private String cachedFingerprint;
+    private AiToolSearchIndex cachedIndex;
+
+    SpringAiToolSearch(SpringAiChatConfiguration configuration) {
+        this.strategy = configuration.getToolSearchStrategy();
+        this.maxResults = configuration.getToolSearchMaxResults();
+        this.embeddingModel = configuration.getToolSearchEmbeddingModel();
+    }
+
+    static void validate(SpringAiChatConfiguration configuration) {
+        if (!configuration.isToolSearch()) {
+            return;
+        }
+        if (configuration.getChatClient() != null) {
+            throw new IllegalArgumentException(
+                    "toolSearch=true requires Camel to build the ChatClient 
from chatModel. "
+                                               + "A prebuilt chatClient cannot 
install the tool search advisor.");
+        }
+        if (configuration.getToolSearchMaxResults() < 1) {
+            throw new IllegalArgumentException("toolSearchMaxResults must be 
at least 1");
+        }
+        if (configuration.getToolSearchStrategy() == null) {
+            throw new IllegalArgumentException("toolSearchStrategy is required 
when toolSearch=true");
+        }
+        if (configuration.getToolSearchStrategy() == 
AiToolSearchStrategy.VECTOR
+                && configuration.getToolSearchEmbeddingModel() == null) {
+            throw new IllegalArgumentException(
+                    "toolSearchStrategy=vector requires 
toolSearchEmbeddingModel and does not fall back to keyword search");
+        }
+    }
+
+    ChatClientRequest initialize(ChatClientRequest request) {
+        if (!request.context().containsKey(CATALOGUE)) {
+            return request;
+        }
+        if (conversationId(request) != null || request.context().get(SESSION) 
instanceof AiToolSearchSession) {
+            return request;
+        }
+        Map<String, Object> context = new HashMap<>(request.context());
+        context.put(SESSION, new AiToolSearchSession());
+        return request.mutate().context(context).build();
+    }
+
+    ChatClientRequest prepare(ChatClientRequest request) {
+        if (!request.context().containsKey(CATALOGUE)) {
+            return request;
+        }
+        List<ToolCallback> catalogue = catalogue(request);
+        Set<String> searchable = searchable(request);
+        Map<String, ToolCallback> byName = new LinkedHashMap<>();
+        for (ToolCallback callback : catalogue) {
+            byName.putIfAbsent(callback.getToolDefinition().name(), callback);
+        }
+        AiToolSearchSession session = session(request);
+        session.retainAvailable(byName.keySet());
+
+        List<ToolCallback> selected = new ArrayList<>();
+        for (ToolCallback callback : byName.values()) {
+            if (!searchable.contains(callback.getToolDefinition().name())) {
+                selected.add(callback);
+            }
+        }
+        selected.add(searchCallback(catalogue, searchable, session));
+        for (String name : session.discoveredNames()) {
+            ToolCallback discovered = byName.get(name);
+            if (discovered != null && searchable.contains(name)) {
+                selected.add(discovered);
+            }
+        }
+        ToolCallingChatOptions options = ((ToolCallingChatOptions) 
request.prompt().getOptions()).mutate()
+                .toolCallbacks(List.copyOf(selected))
+                .build();
+        Prompt prompt = request.prompt().mutate().chatOptions(options).build();
+        return request.mutate().prompt(prompt).build();
+    }
+
+    private ToolCallback searchCallback(
+            List<ToolCallback> catalogue, Set<String> searchable, 
AiToolSearchSession session) {
+        boolean vector = strategy == AiToolSearchStrategy.VECTOR;
+        ToolDefinition definition = ToolDefinition.builder()
+                .name(AiToolSearch.TOOL_NAME)
+                .description(vector
+                        ? "Finds available tools using semantic vector search. 
Matching tools become available to call on the next turn."
+                        : "Finds available tools whose name or description 
contains the given search terms. Matching tools become available to call on the 
next turn.")
+                .inputSchema(vector ? VECTOR_SCHEMA : KEYWORD_SCHEMA)
+                .build();
+        return new ToolCallback() {
+            @Override
+            public ToolDefinition getToolDefinition() {
+                return definition;
+            }
+
+            @Override
+            public String call(String toolInput) {
+                try {
+                    List<AiToolSearchCandidate> hits = vector
+                            ? index(catalogue, 
searchable).searchVector(requiredQuery(toolInput))
+                            : index(catalogue, 
searchable).searchKeyword(requiredTerms(toolInput));
+                    List<String> names = 
hits.stream().map(AiToolSearchCandidate::name).toList();
+                    session.remember(names);
+                    return AiToolSearch.resultMessage(names);
+                } catch (IllegalArgumentException e) {
+                    return "Error: " + e.getMessage();
+                }
+            }
+        };
+    }
+
+    private AiToolSearchIndex index(List<ToolCallback> catalogue, Set<String> 
searchable) {
+        List<AiToolSearchCandidate> candidates = new ArrayList<>();
+        for (ToolCallback callback : catalogue) {
+            String name = callback.getToolDefinition().name();
+            if (!searchable.contains(name) || 
AiToolSearch.TOOL_NAME.equals(name)) {
+                continue;
+            }
+            candidates.add(new AiToolSearchCandidate(
+                    name, callback.getToolDefinition().description(), 
callback.getToolDefinition().inputSchema(), true,
+                    callback));
+        }
+        String fingerprint = fingerprint(candidates);
+        synchronized (indexLock) {
+            if (fingerprint.equals(cachedFingerprint) && cachedIndex != null) {

Review Comment:
   Same check-then-act race as `OpenAIToolSearch.index()` — see comment there.
   
   <!-- generated by an AI agent, Hermès -->



##########
components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIToolSearch.java:
##########
@@ -0,0 +1,329 @@
+/*
+ * 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.openai;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.openai.models.chat.completions.ChatCompletionFunctionTool;
+import com.openai.models.chat.completions.ChatCompletionMessageToolCall;
+import com.openai.models.chat.completions.ChatCompletionTool;
+import com.openai.models.embeddings.CreateEmbeddingResponse;
+import com.openai.models.embeddings.EmbeddingCreateParams;
+import org.apache.camel.Exchange;
+import org.apache.camel.component.ai.tool.AiToolSearch;
+import org.apache.camel.component.ai.tool.AiToolSearchCandidate;
+import org.apache.camel.component.ai.tool.AiToolSearchEmbedder;
+import org.apache.camel.component.ai.tool.AiToolSearchIndex;
+import org.apache.camel.component.ai.tool.AiToolSearchSession;
+import org.apache.camel.component.ai.tool.AiToolSearchStrategy;
+import org.apache.camel.component.ai.tool.AiToolSpec;
+import org.apache.camel.util.ObjectHelper;
+
+/**
+ * Selects the OpenAI tool definitions for one model call when tool search is 
enabled.
+ * <p>
+ * The shared {@link McpToolState} snapshot is not modified. Discovered names 
live on the exchange, and only when
+ * conversation memory is enabled, so concurrent requests do not share them. 
Full definitions are read again from the
+ * latest snapshot on every call. MCP {@code tools/list} is unchanged: search 
only changes which definitions are sent to
+ * the model.
+ */
+final class OpenAIToolSearch {
+
+    static final String SESSION_PROPERTY = "CamelOpenAIToolSearchSession";
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+    private static final TypeReference<Map<String, Object>> MAP_TYPE = new 
TypeReference<>() {
+    };
+
+    private static final String KEYWORD_SCHEMA = 
"{\"type\":\"object\",\"properties\":{\"terms\":{\"type\":\"array\","
+                                                 + "\"description\":\"A list 
of individual search terms used to find relevant tools\","
+                                                 + 
"\"items\":{\"type\":\"string\"}}},\"required\":[\"terms\"]}";
+    private static final String VECTOR_SCHEMA = 
"{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\","
+                                                + "\"description\":\"Natural 
language query describing the desired tool\"}},"
+                                                + "\"required\":[\"query\"]}";
+
+    private final Object indexLock = new Object();
+    private String cachedFingerprint;
+    private AiToolSearchIndex cachedIndex;
+
+    static void validateConfiguration(OpenAIConfiguration configuration) {
+        if (!configuration.isToolSearch()) {
+            return;
+        }
+        if (configuration.getToolSearchMaxResults() < 1) {
+            throw new IllegalArgumentException("toolSearchMaxResults must be 
at least 1");
+        }
+        if (configuration.getToolSearchStrategy() == null) {
+            throw new IllegalArgumentException("toolSearchStrategy is required 
when toolSearch=true");
+        }
+        if (configuration.getToolSearchStrategy() == 
AiToolSearchStrategy.VECTOR
+                && 
ObjectHelper.isEmpty(configuration.getToolSearchEmbeddingModel())) {
+            throw new IllegalArgumentException(
+                    "toolSearchStrategy=vector requires 
toolSearchEmbeddingModel and does not fall back to keyword search");
+        }
+    }
+
+    static void rejectReservedName(McpToolState state) {
+        if (state.knownToolNames().contains(AiToolSearch.TOOL_NAME)) {
+            throw new IllegalArgumentException(
+                    "Tool name '" + AiToolSearch.TOOL_NAME + "' is reserved 
while toolSearch=true");
+        }
+    }
+
+    AiToolSearchSession openSession(Exchange exchange, OpenAIConfiguration 
configuration) {
+        if (configuration.isConversationMemory()) {
+            AiToolSearchSession existing = 
exchange.getProperty(SESSION_PROPERTY, AiToolSearchSession.class);
+            if (existing != null) {
+                return existing;
+            }
+            AiToolSearchSession created = new AiToolSearchSession();
+            exchange.setProperty(SESSION_PROPERTY, created);
+            return created;
+        }
+        AiToolSearchSession created = new AiToolSearchSession();
+        exchange.setProperty(SESSION_PROPERTY, created);
+        return created;
+    }
+
+    void closeSession(Exchange exchange, OpenAIConfiguration configuration) {
+        if (!configuration.isConversationMemory()) {
+            exchange.removeProperty(SESSION_PROPERTY);
+        }
+    }
+
+    List<ChatCompletionTool> toolsFor(OpenAIEndpoint endpoint, 
AiToolSearchSession session) {
+        McpToolState state = endpoint.getMcpToolState();
+        rejectReservedName(state);
+        return select(endpoint.getConfiguration(), state, 
session).stream().map(ChatCompletionTool::ofFunction).toList();
+    }
+
+    McpToolCallExecutor.ToolResult execute(OpenAIEndpoint endpoint, 
ChatCompletionMessageToolCall toolCall, Exchange exchange) {
+        OpenAIConfiguration configuration = endpoint.getConfiguration();
+        AiToolSearchSession session = exchange.getProperty(SESSION_PROPERTY, 
AiToolSearchSession.class);
+        if (session == null) {
+            session = openSession(exchange, configuration);
+        }
+        McpToolState state = endpoint.getMcpToolState();
+        String arguments = toolCall.asFunction().function().arguments();
+        String content;
+        try {
+            content = search(configuration, state, session, arguments, 
index(endpoint, state));
+        } catch (IllegalArgumentException e) {
+            content = "Error: " + e.getMessage();
+        }
+        return new McpToolCallExecutor.ToolResult(
+                toolCall.asFunction().id(), AiToolSearch.TOOL_NAME, content, 
false, 0, !content.startsWith("Error:"));
+    }
+
+    static String search(
+            OpenAIConfiguration configuration, McpToolState state, 
AiToolSearchSession session, String argumentsJson,
+            AiToolSearchIndex index) {
+        List<AiToolSearchCandidate> hits;
+        if (configuration.getToolSearchStrategy() == 
AiToolSearchStrategy.VECTOR) {
+            String query = readString(argumentsJson, 
AiToolSearch.QUERY_ARGUMENT);
+            if (query == null) {
+                return "Error: missing required tool argument '" + 
AiToolSearch.QUERY_ARGUMENT + "'";
+            }
+            hits = index.searchVector(query);
+        } else {
+            List<String> terms = readTerms(argumentsJson);
+            if (terms == null) {
+                return "Error: missing required tool argument '" + 
AiToolSearch.TERMS_ARGUMENT + "'";
+            }
+            hits = index.searchKeyword(terms);
+        }
+        List<String> names = 
hits.stream().map(AiToolSearchCandidate::name).toList();
+        session.remember(names);
+        return AiToolSearch.resultMessage(names);
+    }
+
+    static List<AiToolSearchCandidate> candidates(McpToolState state) {
+        List<AiToolSearchCandidate> candidates = new ArrayList<>();
+        for (ChatCompletionFunctionTool tool : toolsByName(state).values()) {
+            String name = tool.function().name();
+            if (!isSearchable(state, name)) {
+                continue;
+            }
+            AiToolSpec route = state.routeTools().get(name);
+            String description = route != null ? route.getDescription() : 
tool.function().description().orElse(null);
+            String schema = route != null
+                    ? route.getParametersJsonSchema()
+                    : 
tool.function().parameters().map(Object::toString).orElse(null);
+            Object source = route != null ? route : name;
+            candidates.add(new AiToolSearchCandidate(name, description, 
schema, true, source));
+        }
+        return List.copyOf(candidates);
+    }
+
+    static boolean isSearchable(McpToolState state, String name) {
+        AiToolSpec route = state.routeTools().get(name);
+        if (route != null) {
+            return route.isSearchable();
+        }
+        return state.toolClientMap().containsKey(name);
+    }
+
+    static ChatCompletionFunctionTool synthetic(AiToolSearchStrategy strategy) 
{
+        boolean vector = strategy == AiToolSearchStrategy.VECTOR;
+        return AiToolSpecToOpenAI.function(
+                AiToolSearch.TOOL_NAME,
+                vector
+                        ? "Finds available tools using semantic vector search. 
Matching tools become available to call on the next turn."
+                        : "Finds available tools whose name or description 
contains the given search terms. Matching tools become available to call on the 
next turn.",
+                vector ? VECTOR_SCHEMA : KEYWORD_SCHEMA);
+    }
+
+    private AiToolSearchIndex index(OpenAIEndpoint endpoint, McpToolState 
state) {
+        OpenAIConfiguration configuration = endpoint.getConfiguration();
+        String fingerprint = fingerprint(configuration, state);
+        synchronized (indexLock) {
+            if (fingerprint.equals(cachedFingerprint) && cachedIndex != null) {

Review Comment:
   Check-then-act race: two threads can both pass the fingerprint check in the 
first `synchronized` block, then both build the index outside the lock, then 
both overwrite `cachedIndex`. The result is always correct but the index gets 
built twice (potentially twice including embedding calls). Collapse into a 
single `synchronized` block, or use a `ReentrantReadWriteLock` (read-lock for 
the hit path, write-lock for the build path). Same pattern in 
`SpringAiToolSearch`.
   
   <!-- generated by an AI agent, Hermès -->



##########
components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java:
##########
@@ -291,6 +295,35 @@ private static void applyEndpointToolCallingOptions(
         if (endpointConfiguration.getExecuteToolsConcurrently() != null) {
             
agentConfiguration.withExecuteToolsConcurrentlyEnabled(endpointConfiguration.getExecuteToolsConcurrently());
         }
+        if (endpointConfiguration.isToolSearch()) {
+            if (endpointConfiguration.getToolSearchMaxResults() < 1) {
+                throw new IllegalArgumentException("toolSearchMaxResults must 
be at least 1");
+            }
+            if (endpointConfiguration.getToolSearchStrategy() == null) {
+                throw new IllegalArgumentException("toolSearchStrategy is 
required when toolSearch=true");
+            }
+            if (endpointConfiguration.getToolSearchStrategy() == 
AiToolSearchStrategy.VECTOR
+                    && endpointConfiguration.getToolSearchEmbeddingModel() == 
null) {
+                throw new IllegalArgumentException(
+                        "toolSearchStrategy=vector requires 
toolSearchEmbeddingModel and does not fall back to keyword search");
+            }
+            agentConfiguration.withToolSearchStrategy(new 
CamelToolSearchStrategy(
+                    endpointConfiguration.getToolSearchStrategy(),
+                    endpointConfiguration.getToolSearchMaxResults(),
+                    endpointConfiguration.getToolSearchEmbeddingModel()));
+        }
+    }
+
+    private void rejectReservedToolName(LangChain4jAgentConfiguration 
configuration) {
+        if (!configuration.isToolSearch() || configuration.getTags() == null 
|| configuration.getTags().isBlank()) {
+            return;
+        }
+        for (AiToolSpec spec : 
discoverAiToolsByName(configuration.getTags()).values()) {

Review Comment:
   `rejectReservedToolName` only iterates route tools discovered via 
`discoverAiToolsByName`. MCP tools supplied through `mcpClients` are never 
checked, so a user can register an MCP tool named `AiToolSearch.TOOL_NAME` and 
silently shadow the synthetic search tool. Either extend the guard to also 
inspect the MCP tool specs, or document this limitation explicitly.
   
   <!-- generated by an AI agent, Hermès -->



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to