Skip to content

Add validation for Tool annotation names #3887

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@

/**
* The name of the tool. If not provided, the method name will be used.
* <p>
* For maximum compatibility across different LLMs, it is recommended to use only
* alphanumeric characters, underscores, hyphens, and dots in tool names. Using spaces
* or special characters may cause issues with some LLMs (e.g., OpenAI).
* </p>
* <p>
* Examples of recommended names: "get_weather", "search-docs", "tool.v1"
* </p>
* <p>
* Examples of names that may cause compatibility issues: "get weather" (contains
* space), "tool()" (contains parentheses)
* </p>
*/
String name() default "";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.execution.DefaultToolCallResultConverter;
Expand All @@ -38,16 +42,30 @@
*/
public final class ToolUtils {

private static final Logger logger = LoggerFactory.getLogger(ToolUtils.class);

/**
* Regular expression pattern for recommended tool names. Tool names should contain
* only alphanumeric characters, underscores, hyphens, and dots for maximum
* compatibility across different LLMs.
*/
private static final Pattern RECOMMENDED_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_\\.-]+$");

private ToolUtils() {
}

public static String getToolName(Method method) {
Assert.notNull(method, "method cannot be null");
var tool = AnnotatedElementUtils.findMergedAnnotation(method, Tool.class);
String toolName;
if (tool == null) {
return method.getName();
toolName = method.getName();
}
else {
toolName = StringUtils.hasText(tool.name()) ? tool.name() : method.getName();
}
return StringUtils.hasText(tool.name()) ? tool.name() : method.getName();
validateToolName(toolName);
return toolName;
}

public static String getToolDescriptionFromName(String toolName) {
Expand Down Expand Up @@ -102,4 +120,17 @@ public static List<String> getDuplicateToolNames(ToolCallback... toolCallbacks)
return getDuplicateToolNames(Arrays.asList(toolCallbacks));
}

/**
* Validates that a tool name follows recommended naming conventions. Logs a warning
* if the tool name contains characters that may not be compatible with some LLMs.
* @param toolName the tool name to validate
*/
private static void validateToolName(String toolName) {
Assert.hasText(toolName, "Tool name cannot be null or empty");
if (!RECOMMENDED_NAME_PATTERN.matcher(toolName).matches()) {
logger.warn("Tool name '{}' may not be compatible with some LLMs (e.g., OpenAI). "
+ "Consider using only alphanumeric characters, underscores, hyphens, and dots.", toolName);
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed 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
*
* https://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.springframework.ai.tool.support;

import java.lang.reflect.Method;

import org.junit.jupiter.api.Test;

import org.springframework.ai.tool.annotation.Tool;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
* Unit tests for {@link ToolUtils}.
*
* @author Hyunjoon Park
* @since 1.0.0
*/
class ToolUtilsTests {

@Test
void getToolNameFromMethodWithoutAnnotation() throws NoSuchMethodException {
Method method = TestTools.class.getMethod("simpleMethod");
String toolName = ToolUtils.getToolName(method);
assertThat(toolName).isEqualTo("simpleMethod");
}

@Test
void getToolNameFromMethodWithAnnotationButNoName() throws NoSuchMethodException {
Method method = TestTools.class.getMethod("annotatedMethodWithoutName");
String toolName = ToolUtils.getToolName(method);
assertThat(toolName).isEqualTo("annotatedMethodWithoutName");
}

@Test
void getToolNameFromMethodWithValidName() throws NoSuchMethodException {
Method method = TestTools.class.getMethod("methodWithValidName");
String toolName = ToolUtils.getToolName(method);
assertThat(toolName).isEqualTo("valid_tool-name.v1");
}

@Test
void getToolNameFromMethodWithNameContainingSpaces() throws NoSuchMethodException {
// Tool names with spaces are now allowed but will generate a warning log
Method method = TestTools.class.getMethod("methodWithSpacesInName");
String toolName = ToolUtils.getToolName(method);
assertThat(toolName).isEqualTo("invalid tool name");
}

@Test
void getToolNameFromMethodWithNameContainingSpecialChars() throws NoSuchMethodException {
// Tool names with special characters are now allowed but will generate a warning
// log
Method method = TestTools.class.getMethod("methodWithSpecialCharsInName");
String toolName = ToolUtils.getToolName(method);
assertThat(toolName).isEqualTo("tool@name!");
}

@Test
void getToolNameFromMethodWithNameContainingParentheses() throws NoSuchMethodException {
// Tool names with parentheses are now allowed but will generate a warning log
Method method = TestTools.class.getMethod("methodWithParenthesesInName");
String toolName = ToolUtils.getToolName(method);
assertThat(toolName).isEqualTo("tool()");
}

@Test
void getToolNameFromMethodWithEmptyName() throws NoSuchMethodException {
Method method = TestTools.class.getMethod("methodWithEmptyName");
// When name is empty, it falls back to method name which is valid
String toolName = ToolUtils.getToolName(method);
assertThat(toolName).isEqualTo("methodWithEmptyName");
}

@Test
void getToolDescriptionFromMethodWithoutAnnotation() throws NoSuchMethodException {
Method method = TestTools.class.getMethod("simpleMethod");
String description = ToolUtils.getToolDescription(method);
assertThat(description).isEqualTo("simple method");
}

@Test
void getToolDescriptionFromMethodWithAnnotationButNoDescription() throws NoSuchMethodException {
Method method = TestTools.class.getMethod("annotatedMethodWithoutName");
String description = ToolUtils.getToolDescription(method);
assertThat(description).isEqualTo("annotatedMethodWithoutName");
}

@Test
void getToolDescriptionFromMethodWithDescription() throws NoSuchMethodException {
Method method = TestTools.class.getMethod("methodWithDescription");
String description = ToolUtils.getToolDescription(method);
assertThat(description).isEqualTo("This is a tool description");
}

@Test
void getToolNameFromMethodWithUnicodeCharacters() throws NoSuchMethodException {
// Tool names with unicode characters should be allowed for non-English contexts
Method method = TestTools.class.getMethod("methodWithUnicodeName");
String toolName = ToolUtils.getToolName(method);
assertThat(toolName).isEqualTo("获取天气");
}

// Test helper class with various tool methods
public static class TestTools {

public void simpleMethod() {
// Method without @Tool annotation
}

@Tool
public void annotatedMethodWithoutName() {
// Method with @Tool but no name specified
}

@Tool(name = "valid_tool-name.v1")
public void methodWithValidName() {
// Method with valid tool name
}

@Tool(name = "invalid tool name")
public void methodWithSpacesInName() {
// Method with spaces in tool name (invalid)
}

@Tool(name = "tool@name!")
public void methodWithSpecialCharsInName() {
// Method with special characters in tool name (invalid)
}

@Tool(name = "tool()")
public void methodWithParenthesesInName() {
// Method with parentheses in tool name (invalid)
}

@Tool(name = "")
public void methodWithEmptyName() {
// Method with empty name (falls back to method name)
}

@Tool(description = "This is a tool description")
public void methodWithDescription() {
// Method with description
}

@Tool(name = "获取天气")
public void methodWithUnicodeName() {
// Method with unicode characters in tool name (Chinese: "get weather")
}

}

}