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]
