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


##########
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) {
+                return cachedIndex;
+            }
+        }
+        AiToolSearchEmbedder embedder = configuration.getToolSearchStrategy() 
== AiToolSearchStrategy.VECTOR
+                ? embedder(endpoint)
+                : null;
+        AiToolSearchIndex built = AiToolSearchIndex.create(
+                configuration.getToolSearchStrategy(), candidates(state), 
configuration.getToolSearchMaxResults(),
+                embedder);
+        synchronized (indexLock) {
+            cachedFingerprint = fingerprint;
+            cachedIndex = built;
+            return built;
+        }

Review Comment:
   🔴 **Race condition (TOCTOU):** Two disjoint `synchronized(indexLock)` blocks 
with an unguarded `AiToolSearchIndex.create()` call between them. Thread A and 
B can both see a stale fingerprint in the first block, both build an index, 
then race to write. The second writer silently discards the first; worse, 
interleaved assignments can leave `cachedFingerprint` (from B) paired with 
`cachedIndex` (from A — now stale). Use a single synchronized block:
   
   ```java
   synchronized (indexLock) {
       if (fingerprint.equals(cachedFingerprint) && cachedIndex != null) {
           return cachedIndex;
       }
       AiToolSearchEmbedder embedder = configuration.getToolSearchStrategy() == 
AiToolSearchStrategy.VECTOR
               ? embedder(endpoint)
               : null;
       AiToolSearchIndex built = AiToolSearchIndex.create(
               configuration.getToolSearchStrategy(), candidates(state), 
configuration.getToolSearchMaxResults(),
               embedder);
       cachedFingerprint = fingerprint;
       cachedIndex = built;
       return built;
   }
   ```



##########
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()) {
+            if (AiToolSearch.TOOL_NAME.equals(spec.getName())) {
+                throw new IllegalArgumentException(
+                        "Tool name '" + AiToolSearch.TOOL_NAME + "' is 
reserved while toolSearch=true");
+            }
+        }

Review Comment:
   🟡 **MCP tools bypass the reserved-name guard:** `rejectReservedToolName` 
iterates `discoverAiToolsByName(tags)` which covers route tools only. MCP tools 
are loaded later at runtime via `CamelToolSearchStrategy.search()` and are 
never checked here. The OpenAI integration rejects reserved MCP names in 
`OpenAIEndpoint.doStart()` via `mcpToolState.knownToolNames()`. Add the same 
check here (or in `doInit`) once the MCP tool state is available.



##########
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<>();
+
+    /**
+     * Returns the session for a conversation id, creating it on first use.
+     *
+     * @param  conversationId stable id for one conversation; must not be blank
+     * @return                the session for that id
+     */
+    public AiToolSearchSession session(String conversationId) {
+        if (conversationId == null || conversationId.isBlank()) {
+            throw new IllegalArgumentException("A conversation id is required 
for a shared tool-search session");
+        }
+        return sessions.computeIfAbsent(conversationId, id -> new 
AiToolSearchSession());

Review Comment:
   🔴 **Unbounded map — memory leak:** `computeIfAbsent` adds one entry per 
conversation ID with no eviction, no TTL, and no size cap. In a long-running 
server this grows without bound. Consider a bounded LRU map or a `Caffeine` 
cache with a TTL:
   
   ```java
   private final Map<String, AiToolSearchSession> sessions = 
Caffeine.newBuilder()
           .maximumSize(1000)
           .expireAfterAccess(1, TimeUnit.HOURS)
           .<String, AiToolSearchSession>build()
           .asMap();
   ```
   Or at minimum document the expectation that callers evict sessions when a 
conversation ends.



##########
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:
   🟡 **No minimum similarity threshold for vector search:** `searchVector()` 
adds every catalogue entry to `scored` unconditionally, while `searchKeyword()` 
requires `score >= 1`. The top-N vector results may include tools with 
near-zero or negative cosine similarity that are wholly irrelevant. Consider 
filtering by a minimum threshold (e.g. configurable, defaulting to 0.3) before 
passing to `limit()`.



##########
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.

Review Comment:
   🔵 **Javadoc over-promises for vector search:** "Equal scores keep catalogue 
order" derives from `ArrayList.sort` stability and is meaningful for keyword 
search where ties occur. For vector search, cosine scores are practically never 
equal, so the statement is vacuous. Clarify:
   
   ```java
    * Keyword scoring: Equal scores keep catalogue order (sort is stable).
    * Vector scoring: scores are cosine similarity; ties are rare in practice.
   ```



##########
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) {
+                return cachedIndex;
+            }
+        }
+        AiToolSearchEmbedder embedder = strategy == 
AiToolSearchStrategy.VECTOR ? this::embed : null;
+        AiToolSearchIndex built = AiToolSearchIndex.create(strategy, 
candidates, maxResults, embedder);
+        synchronized (indexLock) {
+            cachedFingerprint = fingerprint;
+            cachedIndex = built;
+            return built;
+        }

Review Comment:
   🔴 **Same TOCTOU race as `OpenAIToolSearch`:** Two disjoint 
`synchronized(indexLock)` blocks with an unguarded `AiToolSearchIndex.create()` 
between them. Consolidate into a single synchronized block as shown in the 
sibling class.



##########
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) {
+        Map<String, Object> arguments = readArguments(argumentsJson);
+        if (arguments == null || 
!arguments.containsKey(AiToolSearch.TERMS_ARGUMENT)) {
+            return null;
+        }
+        Object value = arguments.get(AiToolSearch.TERMS_ARGUMENT);
+        if (value instanceof List<?> list) {
+            List<String> terms = new ArrayList<>();
+            for (Object item : list) {
+                if (item != null) {
+                    terms.add(item.toString());
+                }
+            }
+            return terms;
+        }
+        if (value instanceof String text) {
+            return List.of(text);
+        }
+        return null;
+    }
+
+    private static String readString(String argumentsJson, String name) {
+        Map<String, Object> arguments = readArguments(argumentsJson);
+        if (arguments == null || arguments.get(name) == null) {
+            return null;
+        }
+        String text = arguments.get(name).toString().trim();
+        return text.isEmpty() ? null : text;
+    }
+
+    private static Map<String, Object> readArguments(String argumentsJson) {
+        if (argumentsJson == null || argumentsJson.isBlank()) {
+            return null;
+        }
+        try {
+            return OBJECT_MAPPER.readValue(argumentsJson, MAP_TYPE);
+        } catch (Exception e) {
+            return null;
+        }
+    }

Review Comment:
   🟡 **DRY violation — argument parsing duplicated 3×:** `readTerms`, 
`readString`, and `readArguments` appear with near-identical logic in 
`CamelToolSearchStrategy` (here), `OpenAIToolSearch`, and `SpringAiToolSearch`. 
The only variation is the JSON parser (Jackson vs Jsoner). A bug in this path 
must be fixed in all three places. Extract to a shared utility class in 
`camel-ai-tool`, e.g. `AiToolSearchArguments`.



-- 
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