This is an automated email from the ASF dual-hosted git repository.
yuluo-yx pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
The following commit(s) were added to refs/heads/master by this push:
new 1d9edb244c feat: use spring ai MethodToolCallbackProvider replace
custom reflect… (#4234)
1d9edb244c is described below
commit 1d9edb244c085df16547883fa46d97cde7950606
Author: shown <[email protected]>
AuthorDate: Thu Jul 23 20:41:28 2026 +0800
feat: use spring ai MethodToolCallbackProvider replace custom reflect…
(#4234)
Signed-off-by: yuluo-yx <[email protected]>
---
.../hertzbeat/ai/sop/engine/SopEngineImpl.java | 3 +-
.../hertzbeat/ai/sop/registry/ToolRegistry.java | 280 ++++++---------------
.../hertzbeat/ai/sop/engine/SopEngineImplTest.java | 10 +
.../ai/sop/registry/ToolRegistryTest.java | 107 ++++++--
4 files changed, 177 insertions(+), 223 deletions(-)
diff --git
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/engine/SopEngineImpl.java
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/engine/SopEngineImpl.java
index af38c1fdd0..2e672f732a 100644
---
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/engine/SopEngineImpl.java
+++
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/engine/SopEngineImpl.java
@@ -179,7 +179,8 @@ public class SopEngineImpl implements SopEngine {
return resultBuilder.build();
} catch (Exception e) {
- log.error("Error executing SOP {}: {}", definition.getName(),
e.getMessage(), e);
+ String sopName = definition == null ? "unknown" :
definition.getName();
+ log.error("Error executing SOP {}: {}", sopName, e.getMessage(),
e);
return buildFailedResult(resultBuilder, stepResults,
e.getMessage(), startTime);
}
}
diff --git
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/registry/ToolRegistry.java
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/registry/ToolRegistry.java
index bf72956ee7..ec1053d7f2 100644
---
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/registry/ToolRegistry.java
+++
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/registry/ToolRegistry.java
@@ -17,31 +17,38 @@
package org.apache.hertzbeat.ai.sop.registry;
-
import java.lang.reflect.Method;
-import java.lang.reflect.Parameter;
-import java.util.ArrayList;
-import java.util.HashMap;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
+import org.apache.hertzbeat.common.util.JsonUtil;
+import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.annotation.Tool;
-import org.springframework.ai.tool.annotation.ToolParam;
+import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
+import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.ReflectionUtils;
/**
- * Registry for all @Tool annotated methods.
- * Automatically discovers and registers tool methods on first use.
+ * Registry for Spring AI tools.
+ *
+ * <p>Scans beans containing {@link Tool} methods on first use and delegates
argument deserialization,
+ * proxy method resolution, and result conversion to Spring AI's {@link
MethodToolCallbackProvider}.
*/
@Slf4j
@Component
public class ToolRegistry {
private final ApplicationContext applicationContext;
- private final Map<String, ToolMethod> tools = new HashMap<>();
+ private volatile Map<String, RegisteredTool> tools = Map.of();
private volatile boolean initialized = false;
@Autowired
@@ -49,16 +56,11 @@ public class ToolRegistry {
this.applicationContext = applicationContext;
}
- /**
- * Initialize the registry by scanning all beans for @Tool methods.
- * Uses double-checked locking for thread safety.
- */
private void ensureInitialized() {
if (!initialized) {
synchronized (this) {
if (!initialized) {
- log.info("Scanning for @Tool annotated methods...");
- scanAllBeans();
+ tools = scanAllBeans();
initialized = true;
log.info("Registered {} tool methods: {}", tools.size(),
tools.keySet());
}
@@ -66,58 +68,79 @@ public class ToolRegistry {
}
}
- private void scanAllBeans() {
- String[] beanNames = applicationContext.getBeanDefinitionNames();
- for (String beanName : beanNames) {
- try {
- Object bean = applicationContext.getBean(beanName);
- scanBeanForTools(bean);
- } catch (Exception e) {
- // Skip beans that cannot be instantiated
- log.trace("Skipping bean {}: {}", beanName, e.getMessage());
- }
+ private Map<String, RegisteredTool> scanAllBeans() {
+ log.info("Scanning for @Tool annotated methods...");
+ List<Object> toolBeans =
Arrays.stream(applicationContext.getBeanDefinitionNames())
+ .filter(this::containsToolMethod)
+ .map(applicationContext::getBean)
+ .toList();
+
+ if (toolBeans.isEmpty()) {
+ return Map.of();
+ }
+
+ ToolCallback[] callbacks =
MethodToolCallbackProvider.builder().toolObjects(toolBeans.toArray())
+ .build().getToolCallbacks();
+ Map<String, RegisteredTool> discoveredTools = new LinkedHashMap<>();
+ for (ToolCallback callback : callbacks) {
+ String toolName = callback.getToolDefinition().name();
+ discoveredTools.put(toolName, new RegisteredTool(callback,
getRequiredParameters(callback)));
}
+ return Collections.unmodifiableMap(discoveredTools);
}
- private void scanBeanForTools(Object bean) {
- Class<?> clazz = bean.getClass();
-
- // Handle Spring proxies
- if (clazz.getName().contains("$$")) {
- clazz = clazz.getSuperclass();
+ private boolean containsToolMethod(String beanName) {
+ Class<?> beanType = applicationContext.getType(beanName, false);
+ if (beanType == null) {
+ return false;
}
-
- for (Method method : clazz.getMethods()) {
- Tool toolAnnotation = method.getAnnotation(Tool.class);
- if (toolAnnotation != null) {
- String toolName = toolAnnotation.name();
- if (toolName.isEmpty()) {
- toolName = method.getName();
- }
-
- ToolMethod toolMethod = new ToolMethod(bean, method,
toolAnnotation);
- tools.put(toolName, toolMethod);
- log.debug("Registered tool: {} -> {}.{}",
- toolName, clazz.getSimpleName(), method.getName());
- }
+ return
Arrays.stream(ReflectionUtils.getDeclaredMethods(ClassUtils.getUserClass(beanType)))
+ .anyMatch(this::isToolMethod);
+ }
+
+ private boolean isToolMethod(Method method) {
+ return AnnotationUtils.findAnnotation(method, Tool.class) != null;
+ }
+
+ @SuppressWarnings("unchecked")
+ private Set<String> getRequiredParameters(ToolCallback callback) {
+ Map<String, Object> schema =
JsonUtil.fromJson(callback.getToolDefinition().inputSchema(), Map.class);
+ if (schema == null || !(schema.get("required") instanceof List<?>
required)) {
+ return Set.of();
}
+ return required.stream()
+ .map(String::valueOf)
+ .collect(Collectors.toUnmodifiableSet());
}
/**
- * Invoke a tool by name with the given arguments.
+ * Invokes a tool with the provided arguments.
*/
public String invoke(String toolName, Map<String, Object> args) {
ensureInitialized();
- ToolMethod toolMethod = tools.get(toolName);
- if (toolMethod == null) {
- throw new IllegalArgumentException("Unknown tool: " + toolName
+ RegisteredTool tool = tools.get(toolName);
+ if (tool == null) {
+ throw new IllegalArgumentException("Unknown tool: " + toolName
+ ". Available tools: " + tools.keySet());
}
- return toolMethod.invoke(args);
+ Map<String, Object> safeArgs = args == null ? Map.of() : args;
+ validateRequiredParameters(tool.requiredParameters(), safeArgs);
+ String result = tool.callback().call(JsonUtil.toJson(safeArgs));
+ Object convertedResult = JsonUtil.fromJson(result, Object.class);
+ return convertedResult instanceof String text ? text : result;
+ }
+
+ private void validateRequiredParameters(Set<String> requiredParameters,
Map<String, Object> args) {
+ for (String parameter : requiredParameters) {
+ Object value = args.get(parameter);
+ if (value == null || value instanceof String text &&
text.isBlank()) {
+ throw new IllegalArgumentException("Required tool parameter is
missing: " + parameter);
+ }
+ }
}
/**
- * Check if a tool exists.
+ * Checks whether a tool exists.
*/
public boolean hasMethod(String toolName) {
ensureInitialized();
@@ -125,166 +148,13 @@ public class ToolRegistry {
}
/**
- * Get all registered tool names.
+ * Returns an immutable snapshot of all registered tool names.
*/
public Set<String> getToolNames() {
ensureInitialized();
return Set.copyOf(tools.keySet());
}
- /**
- * Get tool method info.
- */
- public ToolMethod getToolMethod(String toolName) {
- ensureInitialized();
- return tools.get(toolName);
- }
-
- /**
- * Represents a registered tool method.
- */
- public static class ToolMethod {
- private final Object bean;
- private final Method method;
- private final Tool annotation;
- private final List<ParamInfo> paramInfos;
-
- public ToolMethod(Object bean, Method method, Tool annotation) {
- this.bean = bean;
- this.method = method;
- this.annotation = annotation;
- this.paramInfos = extractParamInfos(method);
- }
-
- private List<ParamInfo> extractParamInfos(Method method) {
- List<ParamInfo> infos = new ArrayList<>();
- Parameter[] parameters = method.getParameters();
-
- for (Parameter param : parameters) {
- ToolParam toolParam = param.getAnnotation(ToolParam.class);
- String name = (toolParam != null &&
!toolParam.description().isEmpty())
- ? param.getName() : param.getName();
- boolean required = toolParam != null && toolParam.required();
-
- infos.add(new ParamInfo(name, param.getType(), required));
- }
-
- return infos;
- }
-
- /**
- * Invoke this tool method with the given arguments.
- */
- public String invoke(Map<String, Object> args) {
- Map<String, Object> safeArgs = args == null ? Map.of() : args;
- Object[] methodArgs = new Object[paramInfos.size()];
-
- for (int i = 0; i < paramInfos.size(); i++) {
- ParamInfo paramInfo = paramInfos.get(i);
- Object value = safeArgs.get(paramInfo.name);
- if (paramInfo.required && isMissing(value)) {
- throw new IllegalArgumentException("Required tool
parameter is missing: " + paramInfo.name);
- }
- methodArgs[i] = convertValue(value, paramInfo.type);
- }
-
- try {
- Object result = method.invoke(bean, methodArgs);
- return result != null ? result.toString() : "";
-
- } catch (Exception e) {
- log.error("Failed to invoke tool {}: {}", annotation.name(),
e.getMessage(), e);
- throw new RuntimeException("Tool invocation failed: " +
annotation.name(), e);
- }
- }
-
- private boolean isMissing(Object value) {
- return value == null || value instanceof String text &&
text.isBlank();
- }
-
- private Object convertValue(Object value, Class<?> targetType) {
- if (value == null) {
- return null;
- }
-
- if (targetType.isAssignableFrom(value.getClass())) {
- return value;
- }
-
- String strValue = String.valueOf(value);
-
- if (targetType == String.class) {
- return strValue;
- } else if (targetType == Integer.class || targetType == int.class)
{
- return strValue.isEmpty() ? null : Integer.valueOf(strValue);
- } else if (targetType == Long.class || targetType == long.class) {
- return strValue.isEmpty() ? null : Long.valueOf(strValue);
- } else if (targetType == Boolean.class || targetType ==
boolean.class) {
- return Boolean.valueOf(strValue);
- } else if (targetType == Byte.class || targetType == byte.class) {
- return strValue.isEmpty() ? null : Byte.valueOf(strValue);
- } else if (targetType == Double.class || targetType ==
double.class) {
- return strValue.isEmpty() ? null : Double.valueOf(strValue);
- } else if (targetType == Float.class || targetType == float.class)
{
- return strValue.isEmpty() ? null : Float.valueOf(strValue);
- } else if (targetType == List.class) {
- return parseList(strValue);
- }
-
- return value;
- }
-
- @SuppressWarnings("unchecked")
- private List<Long> parseList(String value) {
- if (value == null || value.isEmpty()) {
- return new ArrayList<>();
- }
- List<Long> result = new ArrayList<>();
- for (String s : value.split(",")) {
- if (!s.trim().isEmpty()) {
- result.add(Long.valueOf(s.trim()));
- }
- }
- return result;
- }
-
- public String getName() {
- return annotation.name();
- }
-
- public String getDescription() {
- return annotation.description();
- }
-
- public List<ParamInfo> getParamInfos() {
- return paramInfos;
- }
- }
-
- /**
- * Parameter information for a tool method.
- */
- public static class ParamInfo {
- private final String name;
- private final Class<?> type;
- private final boolean required;
-
- public ParamInfo(String name, Class<?> type, boolean required) {
- this.name = name;
- this.type = type;
- this.required = required;
- }
-
- public String getName() {
- return name;
- }
-
- public Class<?> getType() {
- return type;
- }
-
- public boolean isRequired() {
- return required;
- }
+ private record RegisteredTool(ToolCallback callback, Set<String>
requiredParameters) {
}
}
diff --git
a/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/sop/engine/SopEngineImplTest.java
b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/sop/engine/SopEngineImplTest.java
index 94b281d699..2cd65add8f 100644
---
a/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/sop/engine/SopEngineImplTest.java
+++
b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/sop/engine/SopEngineImplTest.java
@@ -36,6 +36,16 @@ import org.junit.jupiter.api.Test;
*/
class SopEngineImplTest {
+ @Test
+ void executeSyncShouldReturnFailureForNullDefinition() {
+ SopEngineImpl engine = new SopEngineImpl(List.of());
+
+ SopResult result = engine.executeSync(null, Map.of());
+
+ assertEquals("FAILED", result.getStatus());
+ assertTrue(result.getError().contains("must not be null"));
+ }
+
@Test
void executeSyncShouldRejectMissingRequiredParameter() {
SopEngineImpl engine = new SopEngineImpl(List.of(new
RecordingExecutor()));
diff --git
a/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/sop/registry/ToolRegistryTest.java
b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/sop/registry/ToolRegistryTest.java
index c949877762..9e16dbf328 100644
---
a/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/sop/registry/ToolRegistryTest.java
+++
b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/sop/registry/ToolRegistryTest.java
@@ -19,45 +19,118 @@ package org.apache.hertzbeat.ai.sop.registry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
-import java.lang.reflect.Method;
+import java.util.List;
import java.util.Map;
+import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
+import org.springframework.ai.tool.execution.ToolExecutionException;
+import org.springframework.context.support.GenericApplicationContext;
/**
- * Verifies parameter validation and type conversion for registered tool
methods.
+ * Verifies tool discovery, argument validation, and type conversion by Spring
AI callbacks.
*/
class ToolRegistryTest {
@Test
- void invokeShouldRejectMissingRequiredParameter() throws
NoSuchMethodException {
- ToolRegistry.ToolMethod toolMethod = toolMethod();
+ void invokeShouldConvertStructuredArguments() {
+ try (GenericApplicationContext context =
contextWith(ToolFixture.class)) {
+ ToolRegistry registry = new ToolRegistry(context);
- IllegalArgumentException error =
assertThrows(IllegalArgumentException.class,
- () -> toolMethod.invoke(Map.of()));
+ String result = registry.invoke("inspect", Map.of(
+ "monitorId", 42,
+ "ids", List.of(1, 2),
+ "enabled", true));
- assertEquals("Required tool parameter is missing: monitorId",
error.getMessage());
+ assertEquals("Long|Long|Boolean", result);
+ assertTrue(registry.hasMethod("inspect"));
+ assertEquals(Set.of("inspect", "optional"),
registry.getToolNames());
+ }
+ }
+
+ @Test
+ void invokeShouldRejectMissingRequiredParameter() {
+ try (GenericApplicationContext context =
contextWith(ToolFixture.class)) {
+ ToolRegistry registry = new ToolRegistry(context);
+
+ IllegalArgumentException error =
assertThrows(IllegalArgumentException.class,
+ () -> registry.invoke("inspect", Map.of("ids", List.of(1),
"enabled", true)));
+
+ assertEquals("Required tool parameter is missing: monitorId",
error.getMessage());
+ }
}
@Test
- void invokeShouldConvertNumericParameter() throws NoSuchMethodException {
- ToolRegistry.ToolMethod toolMethod = toolMethod();
+ void invokeShouldRejectInvalidBooleanInsteadOfSilentlyUsingFalse() {
+ try (GenericApplicationContext context =
contextWith(ToolFixture.class)) {
+ ToolRegistry registry = new ToolRegistry(context);
- assertEquals("42", toolMethod.invoke(Map.of("monitorId", "42")));
+ assertThrows(ToolExecutionException.class, () ->
registry.invoke("inspect", Map.of(
+ "monitorId", 42,
+ "ids", List.of(1),
+ "enabled", "not-a-boolean")));
+ }
}
- private ToolRegistry.ToolMethod toolMethod() throws NoSuchMethodException {
- Method method = ToolFixture.class.getMethod("query", Long.class);
- return new ToolRegistry.ToolMethod(new ToolFixture(), method,
method.getAnnotation(Tool.class));
+ @Test
+ void initializationShouldRejectDuplicateToolNames() {
+ try (GenericApplicationContext context =
contextWith(ToolFixture.class, DuplicateToolFixture.class)) {
+ ToolRegistry registry = new ToolRegistry(context);
+
+ IllegalArgumentException error = assertThrows(
+ IllegalArgumentException.class, registry::getToolNames);
+
+ assertTrue(error.getMessage().contains("inspect"));
+ }
}
- private static final class ToolFixture {
+ @Test
+ void emptyRegistryShouldReportUnknownTool() {
+ try (GenericApplicationContext context = contextWith()) {
+ ToolRegistry registry = new ToolRegistry(context);
- @Tool(name = "query", description = "测试工具")
- public String query(@ToolParam(description = "监控 ID", required = true)
Long monitorId) {
- return String.valueOf(monitorId);
+ assertTrue(registry.getToolNames().isEmpty());
+ assertThrows(IllegalArgumentException.class, () ->
registry.invoke("missing", null));
}
}
+
+ private GenericApplicationContext contextWith(Class<?>... beanTypes) {
+ GenericApplicationContext context = new GenericApplicationContext();
+ for (Class<?> beanType : beanTypes) {
+ context.registerBean(beanType);
+ }
+ context.refresh();
+ return context;
+ }
+
+ public static final class ToolFixture {
+
+ @Tool(name = "inspect", description = "Inspect argument types")
+ public String inspect(
+ @ToolParam(description = "Monitor ID", required = true) Long
monitorId,
+ @ToolParam(description = "Monitor ID list", required = false)
List<Long> ids,
+ @ToolParam(description = "Whether enabled", required = true)
Boolean enabled) {
+ return "%s|%s|%s".formatted(
+ monitorId.getClass().getSimpleName(),
+ ids.getFirst().getClass().getSimpleName(),
+ enabled.getClass().getSimpleName());
+ }
+
+ @Tool(name = "optional", description = "Tool without arguments")
+ public String optional() {
+ return "optional";
+ }
+ }
+
+ public static final class DuplicateToolFixture {
+
+ @Tool(name = "inspect", description = "Duplicate-name test tool")
+ public String inspect() {
+ return "duplicate";
+ }
+ }
+
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]