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]
