-
Notifications
You must be signed in to change notification settings - Fork 5.2k
CAMEL-24322: Add tool-calling support via AiToolRegistry #25289
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| /* | ||
| * 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.Map; | ||
|
|
||
| import com.fasterxml.jackson.core.type.TypeReference; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.openai.core.JsonValue; | ||
| import com.openai.models.FunctionDefinition; | ||
| import com.openai.models.FunctionParameters; | ||
| import com.openai.models.chat.completions.ChatCompletionFunctionTool; | ||
| import org.apache.camel.component.ai.tool.AiToolSpec; | ||
|
|
||
| /** | ||
| * Converts {@link AiToolSpec} instances to OpenAI {@link ChatCompletionFunctionTool} objects. | ||
| * <p> | ||
| * Uses the pre-built JSON Schema string from {@link AiToolSpec#getParametersJsonSchema()} and parses it into the OpenAI | ||
| * SDK's {@link FunctionParameters} format via {@link JsonValue#from(Object)}. | ||
| */ | ||
| final class AiToolSpecToOpenAI { | ||
|
|
||
| private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); | ||
| private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() { | ||
| }; | ||
|
|
||
| private AiToolSpecToOpenAI() { | ||
| } | ||
|
|
||
| /** | ||
| * Converts an {@link AiToolSpec} to an OpenAI {@link ChatCompletionFunctionTool}. | ||
| * | ||
| * @param spec the tool specification to convert | ||
| * @return the OpenAI function tool definition | ||
| */ | ||
| static ChatCompletionFunctionTool toFunctionTool(AiToolSpec spec) { | ||
| FunctionDefinition.Builder funcBuilder = FunctionDefinition.builder() | ||
| .name(spec.getName()); | ||
|
|
||
| if (spec.getDescription() != null) { | ||
| funcBuilder.description(spec.getDescription()); | ||
| } | ||
|
|
||
| String jsonSchema = spec.getParametersJsonSchema(); | ||
| if (jsonSchema != null && !jsonSchema.isEmpty()) { | ||
| try { | ||
| Map<String, Object> schemaMap = OBJECT_MAPPER.readValue(jsonSchema, MAP_TYPE); | ||
| FunctionParameters.Builder paramsBuilder = FunctionParameters.builder(); | ||
|
|
||
| if (!schemaMap.containsKey("type")) { | ||
| paramsBuilder.putAdditionalProperty("type", JsonValue.from("object")); | ||
| } | ||
| for (Map.Entry<String, Object> entry : schemaMap.entrySet()) { | ||
| paramsBuilder.putAdditionalProperty(entry.getKey(), JsonValue.from(entry.getValue())); | ||
| } | ||
|
|
||
| funcBuilder.parameters(paramsBuilder.build()); | ||
| } catch (Exception e) { | ||
| throw new IllegalArgumentException( | ||
| "Failed to parse JSON Schema for tool '" + spec.getName() + "': " + e.getMessage(), e); | ||
| } | ||
| } | ||
|
|
||
| return ChatCompletionFunctionTool.builder() | ||
| .function(funcBuilder.build()) | ||
| .build(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -24,8 +24,10 @@ | |||||
| import java.util.ArrayList; | ||||||
| import java.util.Base64; | ||||||
| import java.util.Iterator; | ||||||
| import java.util.LinkedHashMap; | ||||||
| import java.util.List; | ||||||
| import java.util.Map; | ||||||
| import java.util.Set; | ||||||
| import java.util.regex.Matcher; | ||||||
| import java.util.regex.Pattern; | ||||||
| import java.util.stream.Collectors; | ||||||
|
|
@@ -59,8 +61,14 @@ | |||||
| import org.apache.camel.Exchange; | ||||||
| import org.apache.camel.Message; | ||||||
| import org.apache.camel.WrappedFile; | ||||||
| import org.apache.camel.component.ai.tool.AiToolExecutor; | ||||||
| import org.apache.camel.component.ai.tool.AiToolParameterHelper; | ||||||
| import org.apache.camel.component.ai.tool.AiToolRegistry; | ||||||
| import org.apache.camel.component.ai.tool.AiToolResult; | ||||||
| import org.apache.camel.component.ai.tool.AiToolSpec; | ||||||
| import org.apache.camel.spi.Synchronization; | ||||||
| import org.apache.camel.support.DefaultAsyncProducer; | ||||||
| import org.apache.camel.support.ExchangeHelper; | ||||||
| import org.apache.camel.support.ResourceHelper; | ||||||
| import org.apache.camel.util.ObjectHelper; | ||||||
| import org.slf4j.Logger; | ||||||
|
|
@@ -204,15 +212,26 @@ private void processInternal(Exchange exchange) throws Exception { | |||||
| } | ||||||
| } | ||||||
|
|
||||||
| // Discover Camel route tools from AiToolRegistry by tags | ||||||
| Map<String, AiToolSpec> camelRouteTools = discoverCamelRouteTools(config); | ||||||
| boolean hasCamelRouteTools = !camelRouteTools.isEmpty(); | ||||||
| if (hasCamelRouteTools) { | ||||||
| for (AiToolSpec spec : camelRouteTools.values()) { | ||||||
| paramsBuilder.addTool(AiToolSpecToOpenAI.toFunctionTool(spec)); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| boolean hasAnyTools = hasMcpTools || hasCamelRouteTools; | ||||||
|
|
||||||
| ChatCompletionCreateParams params = paramsBuilder.build(); | ||||||
|
|
||||||
| if (Boolean.TRUE.equals(streaming) && hasMcpTools && config.isAutoToolExecution()) { | ||||||
| LOG.info("Streaming with MCP tools is not supported; falling back to non-streaming for the agentic loop"); | ||||||
| processNonStreaming(exchange, params, config); | ||||||
| if (Boolean.TRUE.equals(streaming) && hasAnyTools && config.isAutoToolExecution()) { | ||||||
| LOG.info("Streaming with tools is not supported; falling back to non-streaming for the agentic loop"); | ||||||
| processNonStreaming(exchange, params, config, camelRouteTools); | ||||||
| } else if (Boolean.TRUE.equals(streaming)) { | ||||||
| processStreaming(exchange, params); | ||||||
| } else { | ||||||
| processNonStreaming(exchange, params, config); | ||||||
| processNonStreaming(exchange, params, config, camelRouteTools); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -437,17 +456,20 @@ private ChatCompletionContentPart createTextContentPart(String text) { | |||||
| .build()); | ||||||
| } | ||||||
|
|
||||||
| private void processNonStreaming(Exchange exchange, ChatCompletionCreateParams params, OpenAIConfiguration config) | ||||||
| private void processNonStreaming( | ||||||
| Exchange exchange, ChatCompletionCreateParams params, OpenAIConfiguration config, | ||||||
| Map<String, AiToolSpec> camelRouteTools) | ||||||
| throws Exception { | ||||||
| List<ChatCompletionFunctionTool> mcpTools = getEndpoint().getMcpToolState().tools(); | ||||||
| boolean hasMcpTools = mcpTools != null && !mcpTools.isEmpty(); | ||||||
| boolean hasAnyTools = hasMcpTools || !camelRouteTools.isEmpty(); | ||||||
|
|
||||||
| if (!hasMcpTools || !config.isAutoToolExecution()) { | ||||||
| // Path A: No MCP tools or auto-execution disabled -- existing behavior | ||||||
| if (!hasAnyTools || !config.isAutoToolExecution()) { | ||||||
| // Path A: No tools or auto-execution disabled -- existing behavior | ||||||
| processNonStreamingSimple(exchange, params, config); | ||||||
| } else { | ||||||
| // Path B: MCP tools with agentic loop | ||||||
| processNonStreamingAgentic(exchange, params, config); | ||||||
| // Path B: Tools with agentic loop (MCP and/or Camel route tools) | ||||||
| processNonStreamingAgentic(exchange, params, config, camelRouteTools); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -474,12 +496,17 @@ private void processNonStreamingSimple( | |||||
| } | ||||||
|
|
||||||
| private void processNonStreamingAgentic( | ||||||
| Exchange exchange, ChatCompletionCreateParams params, OpenAIConfiguration config) | ||||||
| Exchange exchange, ChatCompletionCreateParams params, OpenAIConfiguration config, | ||||||
| Map<String, AiToolSpec> camelRouteTools) | ||||||
| throws Exception { | ||||||
|
|
||||||
| int maxIterations = config.getMaxToolIterations(); | ||||||
|
|
||||||
| Set<String> availableToolNames = new java.util.LinkedHashSet<>(); | ||||||
| availableToolNames.addAll(getEndpoint().getMcpToolState().toolClientMap().keySet()); | ||||||
| availableToolNames.addAll(camelRouteTools.keySet()); | ||||||
| LOG.debug("Starting agentic loop with maxToolIterations={}, available tools: {}", maxIterations, | ||||||
| getEndpoint().getMcpToolState().toolClientMap().keySet()); | ||||||
| availableToolNames); | ||||||
|
|
||||||
| // Rebuild the builder from the immutable params so we can accumulate messages | ||||||
| ChatCompletionCreateParams.Builder paramsBuilder = params.toBuilder(); | ||||||
|
|
@@ -542,15 +569,26 @@ private void processNonStreamingAgentic( | |||||
| String toolCallId = toolCall.asFunction().id(); | ||||||
| toolCallsLog.add(toolName); | ||||||
|
|
||||||
| // Check if the tool is a Camel route tool first | ||||||
| AiToolSpec camelSpec = camelRouteTools.get(toolName); | ||||||
| if (camelSpec != null) { | ||||||
| String resultContent = executeCamelRouteTool(camelSpec, argsJson, exchange, config); | ||||||
| LOG.debug("Camel route tool '{}' result: {}", toolName, resultContent); | ||||||
| batchResults.add(new ToolResultEntry(toolCallId, resultContent)); | ||||||
| allReturnDirect = false; // Camel route tools do not support returnDirect | ||||||
| continue; | ||||||
| } | ||||||
|
|
||||||
| // Fall back to MCP tool dispatch | ||||||
| McpToolState mcpToolState = getEndpoint().getMcpToolState(); | ||||||
| McpSyncClient mcpClient = mcpToolState.toolClientMap().get(toolName); | ||||||
| if (mcpClient == null) { | ||||||
| if (config.getHallucinatedToolNameStrategy() == HallucinatedToolNameStrategy.FAIL_EXCHANGE) { | ||||||
| throw new IllegalStateException( | ||||||
| "Tool '" + toolName + "' not found in any configured MCP server"); | ||||||
| "Tool '" + toolName + "' not found in any configured tool source"); | ||||||
| } | ||||||
| // repromptModel: send a corrective tool result listing available tools | ||||||
| String available = String.join(", ", mcpToolState.toolClientMap().keySet()); | ||||||
| String available = String.join(", ", availableToolNames); | ||||||
| String errorMsg = "Error: tool '" + toolName | ||||||
| + "' does not exist. Available tools: " + available; | ||||||
| LOG.warn("Hallucinated tool name '{}', sending corrective result to model", toolName); | ||||||
|
|
@@ -633,6 +671,89 @@ private void processNonStreamingAgentic( | |||||
| "Max tool iterations (%d) exceeded. Tools called: %s".formatted(maxIterations, toolCallsLog)); | ||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Code duplication: Consider extracting the shared code into a package-private helper class (similar to how |
||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Executes a Camel route tool via {@link AiToolExecutor}, handling the exchange lifecycle and error strategies. | ||||||
| */ | ||||||
| private String executeCamelRouteTool( | ||||||
| AiToolSpec spec, String argsJson, Exchange exchange, OpenAIConfiguration config) | ||||||
| throws Exception { | ||||||
| LOG.debug("Executing Camel route tool '{}' with args: {}", spec.getName(), argsJson); | ||||||
|
|
||||||
| Map<String, Object> argsMap; | ||||||
| try { | ||||||
| if (argsJson == null || argsJson.trim().isEmpty()) { | ||||||
| argsMap = Map.of(); | ||||||
| } else { | ||||||
| argsMap = OBJECT_MAPPER.readValue(argsJson, Map.class); | ||||||
| } | ||||||
| } catch (JsonProcessingException e) { | ||||||
| if (config.getToolExecutionErrorStrategy() == ToolExecutionErrorStrategy.FAIL_EXCHANGE) { | ||||||
| throw e; | ||||||
| } | ||||||
| LOG.warn("Invalid tool arguments for Camel route tool '{}': {}", spec.getName(), argsJson, e); | ||||||
| return "Error: invalid tool arguments: " + e.getMessage(); | ||||||
| } | ||||||
|
|
||||||
| // Isolate tool execution in its own exchange copy | ||||||
| Exchange toolExchange = ExchangeHelper.createCopy(exchange, true); | ||||||
| try { | ||||||
| AiToolResult result = AiToolExecutor.execute(spec, argsMap, toolExchange); | ||||||
| return handleCamelToolResult(spec.getName(), result, config); | ||||||
| } catch (Exception e) { | ||||||
| if (config.getToolExecutionErrorStrategy() == ToolExecutionErrorStrategy.FAIL_EXCHANGE) { | ||||||
| throw e; | ||||||
| } | ||||||
| LOG.warn("Camel route tool '{}' execution failed: {}", spec.getName(), e.getMessage(), e); | ||||||
| return "Error: Tool execution failed: " + e.getMessage(); | ||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Security nit: The
Suggested change
The same pattern applies to |
||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Converts an {@link AiToolResult} to a string for the LLM, respecting the configured error strategy. | ||||||
| */ | ||||||
| private String handleCamelToolResult(String toolName, AiToolResult result, OpenAIConfiguration config) | ||||||
| throws Exception { | ||||||
| if (result instanceof AiToolResult.Success success) { | ||||||
| return success.value(); | ||||||
| } else if (result instanceof AiToolResult.ArgumentError error) { | ||||||
| if (config.getToolExecutionErrorStrategy() == ToolExecutionErrorStrategy.FAIL_EXCHANGE) { | ||||||
| throw error.cause(); | ||||||
| } | ||||||
| LOG.warn("Camel route tool '{}' argument error: {}", toolName, error.message(), error.cause()); | ||||||
| return "Error: invalid tool arguments: " + error.message(); | ||||||
| } else if (result instanceof AiToolResult.ExecutionError error) { | ||||||
| if (config.getToolExecutionErrorStrategy() == ToolExecutionErrorStrategy.FAIL_EXCHANGE) { | ||||||
| throw error.cause(); | ||||||
| } | ||||||
| LOG.warn("Camel route tool '{}' execution error: {}", toolName, error.message(), error.cause()); | ||||||
| return "Error: Tool execution failed"; | ||||||
| } | ||||||
| return "Tool execution failed"; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Discovers Camel route tools from the shared {@link AiToolRegistry} based on the configured tags. | ||||||
| */ | ||||||
| private Map<String, AiToolSpec> discoverCamelRouteTools(OpenAIConfiguration config) { | ||||||
| String tags = config.getTags(); | ||||||
| if (ObjectHelper.isEmpty(tags)) { | ||||||
| return Map.of(); | ||||||
| } | ||||||
|
|
||||||
| AiToolRegistry registry = AiToolRegistry.getOrCreate(getEndpoint().getCamelContext()); | ||||||
| String[] tagArray = AiToolParameterHelper.splitTags(tags); | ||||||
|
|
||||||
| Map<String, AiToolSpec> toolsByName = new LinkedHashMap<>(); | ||||||
| for (String tag : tagArray) { | ||||||
| for (AiToolSpec spec : registry.getToolsByTag(tag.trim())) { | ||||||
| toolsByName.putIfAbsent(spec.getName(), spec); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| LOG.debug("Discovered {} Camel route tools for tags: {}", toolsByName.size(), tags); | ||||||
| return toolsByName; | ||||||
| } | ||||||
|
|
||||||
| private void setAgenticTokenHeaders(Message message, OpenAIAgenticTokenTracker tokenTracker) { | ||||||
| message.setHeader(OpenAIConstants.AGENTIC_PROMPT_TOKENS, tokenTracker.getPromptTokens()); | ||||||
| message.setHeader(OpenAIConstants.AGENTIC_COMPLETION_TOKENS, tokenTracker.getCompletionTokens()); | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FQCN violation:
new java.util.LinkedHashSet<>()should use the simple class name with an import. The project convention states: "Do NOT use fully qualified class names in Java code." Note thatOpenAIToolExecutionProducerin this same PR correctly importsjava.util.LinkedHashSet.(Also add
import java.util.LinkedHashSet;alongside the existingimport java.util.LinkedHashMap;)