From 1b7caffb4d25977e01f40a57589055d6c472d6f2 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Sat, 11 Apr 2026 16:30:27 +0800 Subject: [PATCH 1/5] feat(tool): support auto tool scan and registration from Spring Context --- .../core/tool/ToolMethodInvoker.java | 43 ++++-- .../java/io/agentscope/core/tool/Toolkit.java | 68 +++++++-- .../tool/ToolMethodInvokerInterruptTest.java | 2 +- .../core/tool/ToolMethodInvokerTest.java | 2 +- .../boot/AgentscopeAutoConfiguration.java | 78 +++++++--- .../boot/properties/AgentscopeProperties.java | 5 + .../boot/properties/ToolProperties.java | 59 ++++++++ .../boot/tool/AgentscopeToolRegistrar.java | 142 ++++++++++++++++++ .../boot/AgentscopeAutoConfigurationTest.java | 4 +- 9 files changed, 349 insertions(+), 54 deletions(-) create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/ToolProperties.java create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/tool/AgentscopeToolRegistrar.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolMethodInvoker.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolMethodInvoker.java index 5da99f2d6..f15fd7305 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolMethodInvoker.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolMethodInvoker.java @@ -44,15 +44,26 @@ class ToolMethodInvoker { /** * Invoke tool method asynchronously with custom converter support. * - * @param toolObject the object containing the method - * @param method the method to invoke + *

To support AOP proxies (e.g., Spring CGLIB or JDK Dynamic Proxies), this method strictly + * separates metadata extraction from the actual reflection execution: + *

+ * + * @param toolObject the object containing the method (can be a proxy instance) + * @param originalMethod the original method used for reading metadata and generic types + * @param executableMethod the actual method to invoke on the toolObject * @param param the tool call parameters containing input, toolUseBlock, agent, and context * @param customConverter custom converter for this invocation (null to use default) * @return Mono containing ToolResultBlock */ Mono invokeAsync( Object toolObject, - Method method, + Method originalMethod, + Method executableMethod, ToolCallParam param, ToolResultConverter customConverter) { // Use custom converter if provided, otherwise use default @@ -64,18 +75,18 @@ Mono invokeAsync( ToolExecutionContext context = param.getContext(); ToolEmitter emitter = param.getEmitter(); - Class returnType = method.getReturnType(); + Class returnType = originalMethod.getReturnType(); if (returnType == CompletableFuture.class) { // Async method returning CompletableFuture: invoke and convert to Mono return Mono.fromCallable( () -> { - method.setAccessible(true); + executableMethod.setAccessible(true); Object[] args = - convertParameters(method, input, agent, context, emitter); + convertParameters(originalMethod, input, agent, context, emitter); @SuppressWarnings("unchecked") CompletableFuture future = - (CompletableFuture) method.invoke(toolObject, args); + (CompletableFuture) executableMethod.invoke(toolObject, args); return future; }) .flatMap( @@ -84,7 +95,7 @@ Mono invokeAsync( .map( r -> converter.convert( - r, extractGenericType(method))) + r, extractGenericType(originalMethod))) .onErrorResume(this::handleError)) .onErrorResume(this::handleError); @@ -92,16 +103,16 @@ r, extractGenericType(method))) // Async method returning Mono: invoke and flatMap return Mono.fromCallable( () -> { - method.setAccessible(true); + executableMethod.setAccessible(true); Object[] args = - convertParameters(method, input, agent, context, emitter); + convertParameters(originalMethod, input, agent, context, emitter); @SuppressWarnings("unchecked") - Mono mono = (Mono) method.invoke(toolObject, args); + Mono mono = (Mono) executableMethod.invoke(toolObject, args); return mono; }) .flatMap( mono -> - mono.map(r -> converter.convert(r, extractGenericType(method))) + mono.map(r -> converter.convert(r, extractGenericType(originalMethod))) .onErrorResume(this::handleError)) .onErrorResume(this::handleError); @@ -109,11 +120,11 @@ r, extractGenericType(method))) // Sync method: wrap in Mono.fromCallable return Mono.fromCallable( () -> { - method.setAccessible(true); + executableMethod.setAccessible(true); Object[] args = - convertParameters(method, input, agent, context, emitter); - Object result = method.invoke(toolObject, args); - return converter.convert(result, method.getGenericReturnType()); + convertParameters(originalMethod, input, agent, context, emitter); + Object result = executableMethod.invoke(toolObject, args); + return converter.convert(result, originalMethod.getGenericReturnType()); }) .onErrorResume(this::handleError); } diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java b/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java index 981398a96..784fb6ed5 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java @@ -152,7 +152,24 @@ public ToolRegistration registration() { * @param toolObject the object containing tool methods */ public void registerTool(Object toolObject) { - registerTool(toolObject, null, null, null); + registerTool(toolObject, null, null, null, null); + } + + /** + * Register a tool object and optionally specify its original target class. + * + *

Specifying the target class is highly recommended when the {@code toolObject} is an AOP + * proxy (e.g., managed by Spring). Since annotations like {@code @Tool} are not typically + * inherited by dynamically generated proxy classes, the toolkit uses the {@code targetClass} + * to scan for metadata, while continuing to route actual executions through the proxy instance + * to preserve aspects (like transactions or logging). + * + * @param toolObject the object containing tool methods (can be a proxy instance) + * @param targetClass the original, unproxied class to scan for @Tool annotations + * (if null, toolObject.getClass() is used) + */ + public void registerTool(Object toolObject, Class targetClass) { + registerTool(toolObject, targetClass, null, null, null); } /** @@ -160,6 +177,7 @@ public void registerTool(Object toolObject) { */ private void registerTool( Object toolObject, + Class targetClass, String groupName, ExtendedModel extendedModel, Map> presetParameters) { @@ -179,19 +197,30 @@ private void registerTool( return; } - Class clazz = toolObject.getClass(); - Method[] methods = clazz.getDeclaredMethods(); + Class clazzToScan = targetClass != null ? targetClass : toolObject.getClass(); + Method[] methods = clazzToScan.getDeclaredMethods(); - for (Method method : methods) { - if (method.isAnnotationPresent(Tool.class)) { - Tool toolAnnotation = method.getAnnotation(Tool.class); + for (Method originalMethod : methods) { + if (originalMethod.isAnnotationPresent(Tool.class)) { + Tool toolAnnotation = originalMethod.getAnnotation(Tool.class); String toolName = - toolAnnotation.name().isEmpty() ? method.getName() : toolAnnotation.name(); + toolAnnotation.name().isEmpty() ? originalMethod.getName() : toolAnnotation.name(); Map toolPresets = (presetParameters != null && presetParameters.containsKey(toolName)) ? presetParameters.get(toolName) : null; - registerToolMethod(toolObject, method, groupName, extendedModel, toolPresets); + + // Find the corresponding real execution method on the proxy object + Method executableMethod = originalMethod; + if (targetClass != null && toolObject.getClass() != targetClass) { + try { + executableMethod = toolObject.getClass().getMethod(originalMethod.getName(), originalMethod.getParameterTypes()); + } catch (NoSuchMethodException e) { + logger.debug("Proxy method not found for {}, falling back to original method", originalMethod.getName()); + } + } + + registerToolMethod(toolObject, originalMethod, executableMethod, groupName, extendedModel, toolPresets); } } } @@ -338,14 +367,15 @@ public List getToolSchemas() { */ private void registerToolMethod( Object toolObject, - Method method, + Method originalMethod, + Method executableMethod, String groupName, ExtendedModel extendedModel, Map presetParameters) { - Tool toolAnnotation = method.getAnnotation(Tool.class); + Tool toolAnnotation = originalMethod.getAnnotation(Tool.class); String toolName = - !toolAnnotation.name().isEmpty() ? toolAnnotation.name() : method.getName(); + !toolAnnotation.name().isEmpty() ? toolAnnotation.name() : originalMethod.getName(); String description = !toolAnnotation.description().isEmpty() ? toolAnnotation.description() @@ -373,14 +403,14 @@ public Map getParameters() { presetParameters != null ? presetParameters.keySet() : Collections.emptySet(); - return schemaGenerator.generateParameterSchema(method, excludeParams); + return schemaGenerator.generateParameterSchema(originalMethod, excludeParams); } @Override public Mono callAsync(ToolCallParam param) { // Pass custom converter to method invoker return methodInvoker.invokeAsync( - toolObject, method, param, customConverter); + toolObject, originalMethod, executableMethod, param, customConverter); } }; @@ -741,6 +771,7 @@ public Toolkit copy() { public static class ToolRegistration { private final Toolkit toolkit; private Object toolObject; + private Class targetClass; private AgentTool agentTool; private McpClientWrapper mcpClientWrapper; private SubAgentProvider subAgentProvider; @@ -766,6 +797,15 @@ public ToolRegistration tool(Object toolObject) { return this; } + /** + * Set the tool object and its original target class (useful for AOP proxies). + */ + public ToolRegistration tool(Object toolObject, Class targetClass) { + this.toolObject = toolObject; + this.targetClass = targetClass; + return this; + } + /** * Set the AgentTool instance to register. * @@ -953,7 +993,7 @@ public void apply() { } if (toolObject != null) { - toolkit.registerTool(toolObject, groupName, extendedModel, presetParameters); + toolkit.registerTool(toolObject, targetClass, groupName, extendedModel, presetParameters); } else if (agentTool != null) { String toolName = agentTool.getName(); Map toolPresets = diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolMethodInvokerInterruptTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolMethodInvokerInterruptTest.java index aa6a46dd7..8ff18f7b6 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolMethodInvokerInterruptTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolMethodInvokerInterruptTest.java @@ -57,7 +57,7 @@ private ToolResultBlock invokeWithParam( ToolUseBlock toolUseBlock = new ToolUseBlock("test-id", method.getName(), input); ToolCallParam param = ToolCallParam.builder().toolUseBlock(toolUseBlock).input(input).build(); - return invoker.invokeAsync(tools, method, param, null).block(); + return invoker.invokeAsync(tools, method, method, param, null).block(); } // Test tools with various error scenarios diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolMethodInvokerTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolMethodInvokerTest.java index 2fb341bef..a79110dc5 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolMethodInvokerTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolMethodInvokerTest.java @@ -51,7 +51,7 @@ private ToolResultBlock invokeWithParam( ToolUseBlock toolUseBlock = new ToolUseBlock("test-id", method.getName(), input); ToolCallParam param = ToolCallParam.builder().toolUseBlock(toolUseBlock).input(input).build(); - return invoker.invokeAsync(tools, method, param, responseConverter).block(); + return invoker.invokeAsync(tools, method, method, param, responseConverter).block(); } // Test class with various method signatures for testing diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java index 7b2b074da..f7dd6e992 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java @@ -23,8 +23,11 @@ import io.agentscope.spring.boot.model.ModelProviderType; import io.agentscope.spring.boot.properties.AgentProperties; import io.agentscope.spring.boot.properties.AgentscopeProperties; +import io.agentscope.spring.boot.tool.AgentscopeToolRegistrar; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -33,8 +36,8 @@ import org.springframework.context.annotation.Scope; /** - * Spring Boot auto-configuration that exposes default Model, Memory, Toolkit - * and ReActAgent beans + * Spring Boot auto-configuration that exposes default Model, Memory, Toolkit, + * ReActAgent beans, and enables automatic @Tool scanning * for AgentScope. * *

@@ -124,43 +127,73 @@ */ @AutoConfiguration @EnableConfigurationProperties(AgentscopeProperties.class) -@ConditionalOnClass(ReActAgent.class) +@ConditionalOnClass({ReActAgent.class, Toolkit.class}) public class AgentscopeAutoConfiguration { /** - * Default Memory implementation backed by InMemoryMemory. + * Global singleton template for the Toolkit. * *

- * Memory is stateful and not thread-safe, so we expose it as a prototype-scoped - * bean. - * In multi-threaded / web environments, it is recommended to obtain instances - * lazily via - * {@code ObjectProvider} or method injection. + * This bean acts as a centralized registry during the Spring application startup phase. + * It receives all automatically scanned and registered Spring Beans annotated with {@code @Tool}. + * It is not intended for direct runtime usage by agents, but rather as a template for cloning. + */ + @Bean(name = "globalAgentscopeToolkit") + @ConditionalOnProperty(prefix = "agentscope.agent", name = "enabled", havingValue = "true") + @ConditionalOnMissingBean(name = "globalAgentscopeToolkit") + public Toolkit globalAgentscopeToolkit() { + return new Toolkit(); + } + + /** + * Default Toolkit implementation for runtime agent usage. + * + *

+ * Toolkit maintains state (such as tool group configurations and runtime modifications) + * and is not thread-safe, so it is exposed as a prototype-scoped bean. + * Each requested instance is created via a deep copy from the {@code globalAgentscopeToolkit} + * template, ensuring isolated state per agent while inheriting all auto-scanned tools. */ @Bean @ConditionalOnProperty(prefix = "agentscope.agent", name = "enabled", havingValue = "true") - @ConditionalOnMissingBean + @ConditionalOnMissingBean(name = "agentscopeToolkit") @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) - public Memory agentscopeMemory() { - return new InMemoryMemory(); + public Toolkit agentscopeToolkit(@Qualifier("globalAgentscopeToolkit") Toolkit globalToolkit) { + return globalToolkit.copy(); } /** - * Default Toolkit implementation with an initially empty tool set. + * Automatic scanner and registrar for AgentScope tools. * *

- * Toolkit holds mutable state and is not thread-safe, so it is also exposed as - * a - * prototype-scoped bean. In application code, prefer obtaining instances lazily - * via - * {@code ObjectProvider} or method injection. + * Runs after all Spring singletons are instantiated to scan the application context for + * methods annotated with {@code @Tool}. Discovered tools are subsequently registered into the + * {@code globalAgentscopeToolkit} template. + */ + @Bean + @ConditionalOnProperty(prefix = "agentscope.tool.auto-scan", name = "enabled", havingValue = "true", matchIfMissing = true) + @ConditionalOnBean(name = "globalAgentscopeToolkit") + public AgentscopeToolRegistrar agentScopeToolRegistrar( + @Qualifier("globalAgentscopeToolkit") Toolkit globalToolkit) { + return new AgentscopeToolRegistrar(globalToolkit); + } + + /** + * Default Memory implementation backed by InMemoryMemory. + * + *

+ * Memory is stateful and not thread-safe, so we expose it as a prototype-scoped + * bean. + * In multi-threaded / web environments, it is recommended to obtain instances + * lazily via + * {@code ObjectProvider} or method injection. */ @Bean @ConditionalOnProperty(prefix = "agentscope.agent", name = "enabled", havingValue = "true") @ConditionalOnMissingBean @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) - public Toolkit agentscopeToolkit() { - return new Toolkit(); + public Memory agentscopeMemory() { + return new InMemoryMemory(); } /** @@ -197,7 +230,10 @@ public Model agentscopeModel(AgentscopeProperties properties) { @ConditionalOnProperty(prefix = "agentscope.agent", name = "enabled", havingValue = "true") @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public ReActAgent agentscopeReActAgent( - Model model, Memory memory, Toolkit toolkit, AgentscopeProperties properties) { + Model model, + Memory memory, + @Qualifier("agentscopeToolkit") Toolkit toolkit, // Inject Toolkit(Prototype) + AgentscopeProperties properties) { AgentProperties config = properties.getAgent(); return ReActAgent.builder() .name(config.getName()) diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentscopeProperties.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentscopeProperties.java index 572076b2b..58ad26266 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentscopeProperties.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentscopeProperties.java @@ -29,6 +29,7 @@ *

  • {@link OpenAIProperties} under {@code agentscope.openai}
  • *
  • {@link GeminiProperties} under {@code agentscope.gemini}
  • *
  • {@link AnthropicProperties} under {@code agentscope.anthropic}
  • + *
  • {@link ToolProperties} under {@code agentscope.tool}
  • * */ @ConfigurationProperties(prefix = "agentscope") @@ -46,6 +47,8 @@ public class AgentscopeProperties { private final AnthropicProperties anthropic = new AnthropicProperties(); + private final ToolProperties tool = new ToolProperties(); + public AgentProperties getAgent() { return agent; } @@ -69,4 +72,6 @@ public GeminiProperties getGemini() { public AnthropicProperties getAnthropic() { return anthropic; } + + public ToolProperties getTool() { return tool; } } diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/ToolProperties.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/ToolProperties.java new file mode 100644 index 000000000..036e0145c --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/ToolProperties.java @@ -0,0 +1,59 @@ +/* + * Copyright 2024-2026 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 + * + * 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 io.agentscope.spring.boot.properties; + +/** + * Tool specific settings. + * + *

    Automatic scanning and registration of Spring Beans annotated with {@code @Tool} + * is enabled by default. + * + *

    Example configuration to disable auto-scanning: + * + *

    {@code
    + * agentscope:
    + *   tool:
    + *     auto-scan:
    + *       enabled: false
    + * }
    + */ +public class ToolProperties { + + /** + * Auto-scan specific settings. + */ + private final AutoScanProperties autoScan = new AutoScanProperties(); + + public AutoScanProperties getAutoScan() { + return autoScan; + } + + /** + * Configuration for automatic tool scanning. + */ + public static class AutoScanProperties { + + private boolean enabled = true; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + } +} \ No newline at end of file diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/tool/AgentscopeToolRegistrar.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/tool/AgentscopeToolRegistrar.java new file mode 100644 index 000000000..0f1a8cfea --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/tool/AgentscopeToolRegistrar.java @@ -0,0 +1,142 @@ +/* + * Copyright 2024-2026 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 + * + * 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 io.agentscope.spring.boot.tool; + +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.Toolkit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.BeanInitializationException; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.MethodIntrospector; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.ClassUtils; + +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Scanner and registrar for AgentScope tools. + * + *

    This component hooks into the Spring lifecycle after all singletons are instantiated. + * It scans the application context for beans containing methods annotated with {@link Tool} + * and registers them with the AgentScope {@link Toolkit}. + * + *

    Built-in fault tolerance includes fail-fast validation for globally unique tool names + * and graceful handling of Spring AOP proxies and {@code @Lazy} initialized beans. + */ +public class AgentscopeToolRegistrar implements SmartInitializingSingleton, ApplicationContextAware { + + private static final Logger log = LoggerFactory.getLogger(AgentscopeToolRegistrar.class); + + private ApplicationContext applicationContext; + private final Toolkit toolkit; + + public AgentscopeToolRegistrar(Toolkit toolkit) { + this.toolkit = toolkit; + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @Override + public void afterSingletonsInstantiated() { + log.info("Start scanning AgentScope @Tool in Spring Context..."); + + if (!(applicationContext instanceof ConfigurableApplicationContext)) { + log.warn("ApplicationContext is not ConfigurableApplicationContext, skip scanning."); + return; + } + + ConfigurableListableBeanFactory beanFactory = ((ConfigurableApplicationContext) applicationContext).getBeanFactory(); + String[] beanNames = beanFactory.getBeanDefinitionNames(); + + // Global tracker to ensure tool names are unique across the application + Set registeredToolNames = new HashSet<>(); + + for (String beanName : beanNames) { + // Skip non-singletons and infrastructure beans to prevent memory leaks and premature initialization + BeanDefinition beanDefinition = null; + if (beanFactory.containsBeanDefinition(beanName)) { + beanDefinition = beanFactory.getBeanDefinition(beanName); + if (!beanDefinition.isSingleton() || beanDefinition.getRole() == BeanDefinition.ROLE_INFRASTRUCTURE) { + continue; + } + } + + // Safely resolve bean type without triggering instantiation + Class beanType = beanFactory.getType(beanName, false); + if (beanType == null || beanType.getName().startsWith("org.springframework.")) { + continue; + } + + // Unwrap CGLIB proxy classes to get the actual user class + Class originalClass = ClassUtils.getUserClass(beanType); + + // Scan for @Tool annotations (supports interfaces and superclasses) + Map annotatedMethods = MethodIntrospector.selectMethods(originalClass, + (MethodIntrospector.MetadataLookup) method -> + AnnotationUtils.findAnnotation(method, Tool.class)); + + // Process beans that actually contain tool methods + if (!annotatedMethods.isEmpty()) { + // Fail-fast validation: Check for tool name collisions before instantiating the bean + for (Map.Entry entry : annotatedMethods.entrySet()) { + Method method = entry.getKey(); + Tool toolAnn = entry.getValue(); + String toolName = toolAnn.name().isEmpty() ? method.getName() : toolAnn.name(); + + if (!registeredToolNames.add(toolName)) { + throw new BeanInitializationException( + String.format("Duplicate AgentScope tool name '%s' found in Spring Bean '%s'. Tool names must be unique globally.", toolName, beanName) + ); + } + } + + // Transparently warn developers if a @Lazy bean is being eagerly initialized + if (beanDefinition != null && beanDefinition.isLazyInit()) { + log.warn("Spring Bean '{}' is marked with @Lazy but contains @Tool methods. It is being forcefully initialized early by AgentScope to register tools.", beanName); + } + + try { + Object bean = applicationContext.getBean(beanName); + + // Pass both the proxy instance (bean) and the original user class (originalClass). + // This ensures AgentScope extracts metadata (like @Tool) from the unproxied class, + // while routing actual method executions through the proxy to preserve Spring AOP aspects. + toolkit.registration() + .tool(bean, originalClass) + .apply(); + + log.info("Successfully registered Spring Bean '{}' (found {} tools) as AgentScope Tool(s).", + beanName, annotatedMethods.size()); + } catch (Exception e) { + throw new BeanInitializationException("Failed to register Spring Bean '" + beanName + "' as AgentScope Tool.", e); + } + } + } + log.info("Finished scanning AgentScope @Tool. Total tools registered: {}", registeredToolNames.size()); + } +} \ No newline at end of file diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeAutoConfigurationTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeAutoConfigurationTest.java index 722e3d527..c0d04acf3 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeAutoConfigurationTest.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeAutoConfigurationTest.java @@ -48,9 +48,11 @@ void shouldCreateDefaultBeansWhenEnabled() { contextRunner.run( context -> { assertThat(context).hasSingleBean(Memory.class); - assertThat(context).hasSingleBean(Toolkit.class); assertThat(context).hasSingleBean(Model.class); assertThat(context).hasSingleBean(ReActAgent.class); + + assertThat(context).hasBean("globalAgentscopeToolkit") + .hasBean("agentscopeToolkit"); }); } From 8615a143a8a2d78174dfd42a813ef338a2f1e6a5 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Sat, 11 Apr 2026 17:57:05 +0800 Subject: [PATCH 2/5] test: add test cases --- .../core/tool/ToolMethodInvoker.java | 29 +- .../java/io/agentscope/core/tool/Toolkit.java | 35 +- .../boot/AgentscopeAutoConfiguration.java | 8 +- .../boot/properties/AgentscopeProperties.java | 4 +- .../boot/properties/ToolProperties.java | 2 +- .../boot/tool/AgentscopeToolRegistrar.java | 106 +++-- .../boot/AgentscopeAutoConfigurationTest.java | 3 +- .../boot/AgentscopeToolAutoScanTest.java | 362 ++++++++++++++++++ 8 files changed, 502 insertions(+), 47 deletions(-) create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeToolAutoScanTest.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolMethodInvoker.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolMethodInvoker.java index f15fd7305..804b2915f 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolMethodInvoker.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolMethodInvoker.java @@ -83,10 +83,12 @@ Mono invokeAsync( () -> { executableMethod.setAccessible(true); Object[] args = - convertParameters(originalMethod, input, agent, context, emitter); + convertParameters( + originalMethod, input, agent, context, emitter); @SuppressWarnings("unchecked") CompletableFuture future = - (CompletableFuture) executableMethod.invoke(toolObject, args); + (CompletableFuture) + executableMethod.invoke(toolObject, args); return future; }) .flatMap( @@ -95,7 +97,9 @@ Mono invokeAsync( .map( r -> converter.convert( - r, extractGenericType(originalMethod))) + r, + extractGenericType( + originalMethod))) .onErrorResume(this::handleError)) .onErrorResume(this::handleError); @@ -105,14 +109,21 @@ r, extractGenericType(originalMethod))) () -> { executableMethod.setAccessible(true); Object[] args = - convertParameters(originalMethod, input, agent, context, emitter); + convertParameters( + originalMethod, input, agent, context, emitter); @SuppressWarnings("unchecked") - Mono mono = (Mono) executableMethod.invoke(toolObject, args); + Mono mono = + (Mono) executableMethod.invoke(toolObject, args); return mono; }) .flatMap( mono -> - mono.map(r -> converter.convert(r, extractGenericType(originalMethod))) + mono.map( + r -> + converter.convert( + r, + extractGenericType( + originalMethod))) .onErrorResume(this::handleError)) .onErrorResume(this::handleError); @@ -122,9 +133,11 @@ r, extractGenericType(originalMethod))) () -> { executableMethod.setAccessible(true); Object[] args = - convertParameters(originalMethod, input, agent, context, emitter); + convertParameters( + originalMethod, input, agent, context, emitter); Object result = executableMethod.invoke(toolObject, args); - return converter.convert(result, originalMethod.getGenericReturnType()); + return converter.convert( + result, originalMethod.getGenericReturnType()); }) .onErrorResume(this::handleError); } diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java b/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java index 784fb6ed5..abaaa59ff 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java @@ -204,7 +204,9 @@ private void registerTool( if (originalMethod.isAnnotationPresent(Tool.class)) { Tool toolAnnotation = originalMethod.getAnnotation(Tool.class); String toolName = - toolAnnotation.name().isEmpty() ? originalMethod.getName() : toolAnnotation.name(); + toolAnnotation.name().isEmpty() + ? originalMethod.getName() + : toolAnnotation.name(); Map toolPresets = (presetParameters != null && presetParameters.containsKey(toolName)) ? presetParameters.get(toolName) @@ -214,13 +216,26 @@ private void registerTool( Method executableMethod = originalMethod; if (targetClass != null && toolObject.getClass() != targetClass) { try { - executableMethod = toolObject.getClass().getMethod(originalMethod.getName(), originalMethod.getParameterTypes()); + executableMethod = + toolObject + .getClass() + .getMethod( + originalMethod.getName(), + originalMethod.getParameterTypes()); } catch (NoSuchMethodException e) { - logger.debug("Proxy method not found for {}, falling back to original method", originalMethod.getName()); + logger.debug( + "Proxy method not found for {}, falling back to original method", + originalMethod.getName()); } } - registerToolMethod(toolObject, originalMethod, executableMethod, groupName, extendedModel, toolPresets); + registerToolMethod( + toolObject, + originalMethod, + executableMethod, + groupName, + extendedModel, + toolPresets); } } } @@ -403,14 +418,19 @@ public Map getParameters() { presetParameters != null ? presetParameters.keySet() : Collections.emptySet(); - return schemaGenerator.generateParameterSchema(originalMethod, excludeParams); + return schemaGenerator.generateParameterSchema( + originalMethod, excludeParams); } @Override public Mono callAsync(ToolCallParam param) { // Pass custom converter to method invoker return methodInvoker.invokeAsync( - toolObject, originalMethod, executableMethod, param, customConverter); + toolObject, + originalMethod, + executableMethod, + param, + customConverter); } }; @@ -993,7 +1013,8 @@ public void apply() { } if (toolObject != null) { - toolkit.registerTool(toolObject, targetClass, groupName, extendedModel, presetParameters); + toolkit.registerTool( + toolObject, targetClass, groupName, extendedModel, presetParameters); } else if (agentTool != null) { String toolName = agentTool.getName(); Map toolPresets = diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java index f7dd6e992..46d16c751 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java @@ -171,7 +171,11 @@ public Toolkit agentscopeToolkit(@Qualifier("globalAgentscopeToolkit") Toolkit g * {@code globalAgentscopeToolkit} template. */ @Bean - @ConditionalOnProperty(prefix = "agentscope.tool.auto-scan", name = "enabled", havingValue = "true", matchIfMissing = true) + @ConditionalOnProperty( + prefix = "agentscope.tool.auto-scan", + name = "enabled", + havingValue = "true", + matchIfMissing = true) @ConditionalOnBean(name = "globalAgentscopeToolkit") public AgentscopeToolRegistrar agentScopeToolRegistrar( @Qualifier("globalAgentscopeToolkit") Toolkit globalToolkit) { @@ -232,7 +236,7 @@ public Model agentscopeModel(AgentscopeProperties properties) { public ReActAgent agentscopeReActAgent( Model model, Memory memory, - @Qualifier("agentscopeToolkit") Toolkit toolkit, // Inject Toolkit(Prototype) + @Qualifier("agentscopeToolkit") Toolkit toolkit, // Inject Toolkit(Prototype) AgentscopeProperties properties) { AgentProperties config = properties.getAgent(); return ReActAgent.builder() diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentscopeProperties.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentscopeProperties.java index 58ad26266..e8559637c 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentscopeProperties.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentscopeProperties.java @@ -73,5 +73,7 @@ public AnthropicProperties getAnthropic() { return anthropic; } - public ToolProperties getTool() { return tool; } + public ToolProperties getTool() { + return tool; + } } diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/ToolProperties.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/ToolProperties.java index 036e0145c..67e629984 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/ToolProperties.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/ToolProperties.java @@ -56,4 +56,4 @@ public void setEnabled(boolean enabled) { this.enabled = enabled; } } -} \ No newline at end of file +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/tool/AgentscopeToolRegistrar.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/tool/AgentscopeToolRegistrar.java index 0f1a8cfea..bcfd526f3 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/tool/AgentscopeToolRegistrar.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/tool/AgentscopeToolRegistrar.java @@ -17,6 +17,11 @@ import io.agentscope.core.tool.Tool; import io.agentscope.core.tool.Toolkit; +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.BeanInitializationException; @@ -30,11 +35,6 @@ import org.springframework.core.annotation.AnnotationUtils; import org.springframework.util.ClassUtils; -import java.lang.reflect.Method; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; - /** * Scanner and registrar for AgentScope tools. * @@ -45,7 +45,8 @@ *

    Built-in fault tolerance includes fail-fast validation for globally unique tool names * and graceful handling of Spring AOP proxies and {@code @Lazy} initialized beans. */ -public class AgentscopeToolRegistrar implements SmartInitializingSingleton, ApplicationContextAware { +public class AgentscopeToolRegistrar + implements SmartInitializingSingleton, ApplicationContextAware { private static final Logger log = LoggerFactory.getLogger(AgentscopeToolRegistrar.class); @@ -70,18 +71,21 @@ public void afterSingletonsInstantiated() { return; } - ConfigurableListableBeanFactory beanFactory = ((ConfigurableApplicationContext) applicationContext).getBeanFactory(); + ConfigurableListableBeanFactory beanFactory = + ((ConfigurableApplicationContext) applicationContext).getBeanFactory(); String[] beanNames = beanFactory.getBeanDefinitionNames(); // Global tracker to ensure tool names are unique across the application Set registeredToolNames = new HashSet<>(); for (String beanName : beanNames) { - // Skip non-singletons and infrastructure beans to prevent memory leaks and premature initialization + // Skip non-singletons and infrastructure beans to prevent memory leaks and premature + // initialization BeanDefinition beanDefinition = null; if (beanFactory.containsBeanDefinition(beanName)) { beanDefinition = beanFactory.getBeanDefinition(beanName); - if (!beanDefinition.isSingleton() || beanDefinition.getRole() == BeanDefinition.ROLE_INFRASTRUCTURE) { + if (!beanDefinition.isSingleton() + || beanDefinition.getRole() == BeanDefinition.ROLE_INFRASTRUCTURE) { continue; } } @@ -96,13 +100,16 @@ public void afterSingletonsInstantiated() { Class originalClass = ClassUtils.getUserClass(beanType); // Scan for @Tool annotations (supports interfaces and superclasses) - Map annotatedMethods = MethodIntrospector.selectMethods(originalClass, - (MethodIntrospector.MetadataLookup) method -> - AnnotationUtils.findAnnotation(method, Tool.class)); + Map annotatedMethods = + MethodIntrospector.selectMethods( + originalClass, + (MethodIntrospector.MetadataLookup) + method -> AnnotationUtils.findAnnotation(method, Tool.class)); // Process beans that actually contain tool methods if (!annotatedMethods.isEmpty()) { - // Fail-fast validation: Check for tool name collisions before instantiating the bean + // Fail-fast validation: Check for tool name collisions before instantiating the + // bean for (Map.Entry entry : annotatedMethods.entrySet()) { Method method = entry.getKey(); Tool toolAnn = entry.getValue(); @@ -110,33 +117,78 @@ public void afterSingletonsInstantiated() { if (!registeredToolNames.add(toolName)) { throw new BeanInitializationException( - String.format("Duplicate AgentScope tool name '%s' found in Spring Bean '%s'. Tool names must be unique globally.", toolName, beanName) - ); + String.format( + "Duplicate AgentScope tool name '%s' found in Spring Bean" + + " '%s'. Tool names must be unique globally.", + toolName, beanName)); } } // Transparently warn developers if a @Lazy bean is being eagerly initialized if (beanDefinition != null && beanDefinition.isLazyInit()) { - log.warn("Spring Bean '{}' is marked with @Lazy but contains @Tool methods. It is being forcefully initialized early by AgentScope to register tools.", beanName); + log.warn( + "Spring Bean '{}' is marked with @Lazy but contains @Tool methods. It" + + " is being forcefully initialized early by AgentScope to register" + + " tools.", + beanName); } try { Object bean = applicationContext.getBean(beanName); - // Pass both the proxy instance (bean) and the original user class (originalClass). - // This ensures AgentScope extracts metadata (like @Tool) from the unproxied class, - // while routing actual method executions through the proxy to preserve Spring AOP aspects. - toolkit.registration() - .tool(bean, originalClass) - .apply(); + // Build the complete class hierarchy tree (including all superclasses and + // interfaces) + Set> hierarchy = new LinkedHashSet<>(); + Class current = originalClass; + while (current != null && current != Object.class) { + hierarchy.add(current); + current = current.getSuperclass(); + } + hierarchy.addAll(ClassUtils.getAllInterfacesForClassAsSet(originalClass)); + + // Filter the hierarchy to find ONLY the classes/interfaces declaring @Tool. + Set> classesToScan = new LinkedHashSet<>(); + for (Class clazz : hierarchy) { + // Skip Java and Spring internal interfaces + if (clazz.getName().startsWith("java.") + || clazz.getName().startsWith("org.springframework.")) { + continue; + } + + // Inspect the declared methods to verify if this class/interface contains + // tools + for (Method m : clazz.getDeclaredMethods()) { + if (m.isAnnotationPresent(Tool.class)) { + classesToScan.add(clazz); + break; + } + } + } + + for (Class clazzToScan : classesToScan) { + // Pass both the proxy instance (bean) and the specific declaring + // class/interface (clazzToScan). + // This ensures AgentScope extracts metadata (like @Tool) from the exact + // interface/superclass, + // while still routing actual method executions through the proxy to + // preserve Spring AOP aspects. + toolkit.registration().tool(bean, clazzToScan).apply(); + } - log.info("Successfully registered Spring Bean '{}' (found {} tools) as AgentScope Tool(s).", - beanName, annotatedMethods.size()); + log.info( + "Successfully registered Spring Bean '{}' (found {} tools) as" + + " AgentScope Tool(s).", + beanName, + annotatedMethods.size()); } catch (Exception e) { - throw new BeanInitializationException("Failed to register Spring Bean '" + beanName + "' as AgentScope Tool.", e); + throw new BeanInitializationException( + "Failed to register Spring Bean '" + beanName + "' as AgentScope Tool.", + e); } } } - log.info("Finished scanning AgentScope @Tool. Total tools registered: {}", registeredToolNames.size()); + log.info( + "Finished scanning AgentScope @Tool. Total tools registered: {}", + registeredToolNames.size()); } -} \ No newline at end of file +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeAutoConfigurationTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeAutoConfigurationTest.java index c0d04acf3..df3fd4939 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeAutoConfigurationTest.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeAutoConfigurationTest.java @@ -51,7 +51,8 @@ void shouldCreateDefaultBeansWhenEnabled() { assertThat(context).hasSingleBean(Model.class); assertThat(context).hasSingleBean(ReActAgent.class); - assertThat(context).hasBean("globalAgentscopeToolkit") + assertThat(context) + .hasBean("globalAgentscopeToolkit") .hasBean("agentscopeToolkit"); }); } diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeToolAutoScanTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeToolAutoScanTest.java new file mode 100644 index 000000000..8e7fca009 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeToolAutoScanTest.java @@ -0,0 +1,362 @@ +/* + * Copyright 2024-2026 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 + * + * 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 io.agentscope.spring.boot; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.Toolkit; +import io.agentscope.spring.boot.tool.AgentscopeToolRegistrar; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.BeanInitializationException; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.annotation.Scope; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; + +/** + * Tests for the {@link Tool} auto-scanning and registration capabilities. + * + * Tests cover: + * - Auto-scanning configuration enablement and disablement + * - Standard Spring bean registration + * - Handling of AOP-proxied beans (e.g., CGLIB proxy via @Async) + * - Eager initialization of @Lazy beans + * - Skipping prototype-scoped beans to prevent memory leaks + * - Fail-fast validation for duplicate tool names + * - Detection of @Tool annotations declared on interfaces + * - Detection of @Tool annotations declared on superclasses + * - Registration of multiple @Tool methods from a single bean + * - Conditional bean registration via @ConditionalOnProperty + */ +class AgentscopeToolAutoScanTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(AgentscopeAutoConfiguration.class)) + .withPropertyValues( + "agentscope.agent.enabled=true", + "agentscope.dashscope.api-key=test-api-key"); + + @Test + void shouldCreateRegistrarWhenAutoScanEnabledByDefault() { + contextRunner.run( + context -> { + assertThat(context).hasSingleBean(AgentscopeToolRegistrar.class); + }); + } + + @Test + void shouldNotCreateRegistrarWhenAutoScanDisabled() { + contextRunner + .withPropertyValues("agentscope.tool.auto-scan.enabled=false") + .run( + context -> { + assertThat(context).doesNotHaveBean(AgentscopeToolRegistrar.class); + }); + } + + @Test + void shouldRegisterNormalToolBeanSuccessfully() { + contextRunner + .withUserConfiguration(NormalToolConfig.class) + .run( + context -> { + Toolkit globalToolkit = + context.getBean("globalAgentscopeToolkit", Toolkit.class); + assertThat(globalToolkit.getToolNames()).contains("calculator_add"); + }); + } + + @Test + void shouldHandleAndRegisterAopProxiedToolBeans() { + contextRunner + .withUserConfiguration(AopToolConfig.class) + .run( + context -> { + Toolkit globalToolkit = + context.getBean("globalAgentscopeToolkit", Toolkit.class); + assertThat(globalToolkit.getToolNames()).contains("async_action"); + }); + } + + @Test + void shouldForceInitAndRegisterLazyToolBeans() { + contextRunner + .withUserConfiguration(LazyToolConfig.class) + .run( + context -> { + assertThat(LazyService.initialized).isTrue(); + Toolkit globalToolkit = + context.getBean("globalAgentscopeToolkit", Toolkit.class); + assertThat(globalToolkit.getToolNames()).contains("lazy_action"); + }); + } + + @Test + void shouldSkipPrototypeBeansToPreventMemoryLeaks() { + contextRunner + .withUserConfiguration(PrototypeToolConfig.class) + .run( + context -> { + Toolkit globalToolkit = + context.getBean("globalAgentscopeToolkit", Toolkit.class); + assertThat(globalToolkit.getToolNames()) + .doesNotContain("prototype_action"); + }); + } + + @Test + void shouldFailFastWhenDuplicateToolNamesDetected() { + contextRunner + .withUserConfiguration(DuplicateToolConfig.class) + .run( + context -> { + assertThat(context.getStartupFailure()).isNotNull(); + assertThat(context.getStartupFailure()) + .isInstanceOf(BeanInitializationException.class) + .hasMessageContaining( + "Duplicate AgentScope tool name 'common_search'"); + }); + } + + @Test + void shouldRegisterToolDeclaredOnInterface() { + contextRunner + .withUserConfiguration(InterfaceToolConfig.class) + .run( + context -> { + Toolkit globalToolkit = + context.getBean("globalAgentscopeToolkit", Toolkit.class); + assertThat(globalToolkit.getToolNames()).contains("interface_action"); + }); + } + + @Test + void shouldRegisterToolDeclaredOnSuperclass() { + contextRunner + .withUserConfiguration(InheritanceToolConfig.class) + .run( + context -> { + Toolkit globalToolkit = + context.getBean("globalAgentscopeToolkit", Toolkit.class); + assertThat(globalToolkit.getToolNames()).contains("superclass_action"); + }); + } + + @Test + void shouldRegisterMultipleToolsFromSingleBean() { + contextRunner + .withUserConfiguration(MultiToolConfig.class) + .run( + context -> { + Toolkit globalToolkit = + context.getBean("globalAgentscopeToolkit", Toolkit.class); + assertThat(globalToolkit.getToolNames()) + .contains("math_multiply", "math_divide"); + }); + } + + @Test + void shouldRespectConditionalOnPropertyForToolBeans() { + // Case 1: Condition not met, do not register this tool + contextRunner + .withUserConfiguration(ConditionalToolConfig.class) + .run( + context -> { + Toolkit globalToolkit = + context.getBean("globalAgentscopeToolkit", Toolkit.class); + assertThat(globalToolkit.getToolNames()) + .doesNotContain("conditional_action"); + }); + + // Case 2: Condition is met, register this tool + contextRunner + .withUserConfiguration(ConditionalToolConfig.class) + .withPropertyValues("feature.custom-tool.enabled=true") + .run( + context -> { + Toolkit globalToolkit = + context.getBean("globalAgentscopeToolkit", Toolkit.class); + assertThat(globalToolkit.getToolNames()).contains("conditional_action"); + }); + } + + // ==================================================================================== + // Internal testing configuration class (Mock Beans) + // ==================================================================================== + + @Configuration + static class NormalToolConfig { + @Bean + public CalculatorTool calculatorTool() { + return new CalculatorTool(); + } + } + + static class CalculatorTool { + @Tool(name = "calculator_add", description = "Adds two integers") + public int add(int a, int b) { + return a + b; + } + } + + @Configuration + @EnableAsync + static class AopToolConfig { + @Bean + public AsyncService asyncService() { + return new AsyncService(); + } + } + + static class AsyncService { + @Async // Trigger Spring CGLIB proxy + @Tool(name = "async_action", description = "Executes an action asynchronously") + public void doAction() {} + } + + @Configuration + static class LazyToolConfig { + @Bean + @Lazy + public LazyService lazyService() { + return new LazyService(); + } + } + + static class LazyService { + public static boolean initialized = false; + + public LazyService() { + initialized = true; + } + + @Tool(name = "lazy_action", description = "Lazy tool") + public void execute() {} + } + + @Configuration + static class PrototypeToolConfig { + @Bean + @Scope("prototype") + public PrototypeService prototypeService() { + return new PrototypeService(); + } + } + + static class PrototypeService { + @Tool(name = "prototype_action", description = "Prototype tool") + public void execute() {} + } + + @Configuration + static class DuplicateToolConfig { + @Bean + public SearchServiceOne serviceOne() { + return new SearchServiceOne(); + } + + @Bean + public SearchServiceTwo serviceTwo() { + return new SearchServiceTwo(); + } + } + + static class SearchServiceOne { + @Tool(name = "common_search") + public void searchWeb() {} + } + + static class SearchServiceTwo { + @Tool(name = "common_search") + public void searchDatabase() {} + } + + @Configuration + static class InterfaceToolConfig { + @Bean + public WebService webService() { + return new WebServiceImpl(); + } + } + + interface WebService { + @Tool(name = "interface_action", description = "Tool defined on interface") + void executeWebTask(); + } + + static class WebServiceImpl implements WebService { + @Override + public void executeWebTask() {} + } + + @Configuration + static class InheritanceToolConfig { + @Bean + public ChildService childService() { + return new ChildService(); + } + } + + static class BaseService { + @Tool(name = "superclass_action", description = "Tool defined on superclass") + public void executeBaseTask() {} + } + + static class ChildService extends BaseService { + // Only inherit superclass + } + + @Configuration + static class MultiToolConfig { + @Bean + public MathService mathService() { + return new MathService(); + } + } + + static class MathService { + @Tool(name = "math_multiply", description = "Multiplies two integers") + public int multiply(int a, int b) { + return a * b; + } + + @Tool(name = "math_divide", description = "Divides two integers") + public int divide(int a, int b) { + return a / b; + } + } + + @Configuration + static class ConditionalToolConfig { + @Bean + @ConditionalOnProperty(name = "feature.custom-tool.enabled", havingValue = "true") + public ConditionalService conditionalService() { + return new ConditionalService(); + } + } + + static class ConditionalService { + @Tool(name = "conditional_action", description = "Conditionally loaded tool") + public void execute() {} + } +} From b6c40ea5244472eccfc4152e459c50bd387d28d2 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Sat, 11 Apr 2026 19:18:57 +0800 Subject: [PATCH 3/5] docs: add usage documentation --- docs/en/task/tool.md | 57 ++++++++++++++++++++++++++++++++++++++++++++ docs/zh/task/tool.md | 57 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/docs/en/task/tool.md b/docs/en/task/tool.md index 47ae89305..3ee4ed932 100644 --- a/docs/en/task/tool.md +++ b/docs/en/task/tool.md @@ -10,6 +10,7 @@ The tool system enables agents to perform external operations such as API calls, - **Tool Groups**: Dynamically activate/deactivate tool collections - **Preset Parameters**: Hide sensitive parameters (e.g., API Keys) - **Parallel Execution**: Support parallel invocation of multiple tools +- **Spring Boot Auto-Scan**: Automatically discovers and registers all Spring Bean methods annotated with `@Tool` ## Quick Start @@ -95,6 +96,62 @@ public ToolResultBlock generate( | `Flux` | Streaming execution | | `ToolResultBlock` | Direct control over return format (text, image, error, etc.) | +## Spring Boot Auto-Scanning and Registration + +In Spring Boot projects, including the `agentscope-spring-boot-starter` enables the framework to automatically scan the Spring container for all Bean methods annotated with `@Tool`. These methods are registered as global tools, eliminating the need to call `registerTool` manually. + +### Add Dependency + +```xml + + io.agentscope + agentscope-spring-boot-starter + +``` + +### Usage + +Directly declare a class containing `@Tool` methods as a Spring Bean (e.g., using `@Component` or `@Service`): + +```java +import org.springframework.stereotype.Service; +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolParam; + +@Service +public class OrderService { + + // This method will be automatically scanned and wrapped as an AgentScope Tool + @Tool(name = "get_order", description = "Retrieve order details") + public String getOrder(@ToolParam(description = "Order ID") String orderId) { + return "The status for order " + orderId + " is: Shipped"; + } +} +``` + +### Configuration Switch + +The auto-scanning feature is **enabled** by default. If you encounter naming conflicts, security policy restrictions, or have extreme startup performance requirements, you can disable it at any time through your Spring Boot configuration file (`application.yml` or `application.properties`): + +```yaml +agentscope: + tool: + auto-scan: + enabled: false # Set to false to completely disable auto-scanning +``` + +### Advanced Feature Support + +* **Spring AOP Proxy Support**: Whether a Bean is enhanced by `@Transactional`, `@Async`, or custom aspects (via CGLIB or JDK dynamic proxies), the framework accurately extracts the `@Tool` annotation from the original target class. It also preserves the full Spring aspect interception logic during final execution. +* **Interface and Superclass Inheritance**: The framework supports defining `@Tool` annotations on interfaces or superclasses. Implementation classes only need to maintain standard inheritance relationships to be automatically scanned and registered. + +### Important Notes and Fault Tolerance + +* **Safe Scanning Timing**: The auto-scanning process is triggered after all singleton Beans in the Spring container are initialized (based on `SmartInitializingSingleton`). This timing avoids "early initialization" issues that can lead to dependency cycle problems. +* **Fail-Fast for Naming Conflicts**: If the scanner detects identical `@Tool(name="...")` values across different Beans, it throws a `BeanInitializationException` during the application startup phase. This halts the startup to prevent runtime routing confusion. +* **`@Lazy` Initialization Warning**: If a Bean marked with `@Lazy` contains tool methods, the framework forcefully triggers the initialization of that Bean. A `WARN` log is issued to notify the developer of this early initialization. +* **Prototype and Infrastructure Bean Filtering**: To prevent memory leaks and state inconsistency, the scanner automatically filters out prototype-scoped Beans (`@Scope("prototype")`) and Spring internal infrastructure Beans. + ## Tool Groups Manage tools by scenario with dynamic activation/deactivation: diff --git a/docs/zh/task/tool.md b/docs/zh/task/tool.md index 7ade449d4..9ca47494f 100644 --- a/docs/zh/task/tool.md +++ b/docs/zh/task/tool.md @@ -10,6 +10,7 @@ - **工具组管理**:动态激活/停用工具集合 - **预设参数**:隐藏敏感参数(如 API Key) - **并行执行**:支持多工具并行调用 +- **Spring Boot 自动扫描**:自动发现并注册所有标注 `@Tool` 的 Spring Bean 方法 ## 快速开始 @@ -95,6 +96,62 @@ public ToolResultBlock generate( | `Flux` | 流式执行 | | `ToolResultBlock` | 直接控制返回格式(文本、图片、错误等) | +## Spring Boot 自动扫描注册 + +在 Spring Boot 项目中,如果您引入了 `agentscope-spring-boot-starter`,框架会自动扫描 Spring 容器中所有带有 `@Tool` 注解的 Bean 方法,并将它们注册为全局工具,**无需手动调用 `registerTool`**。 + +### 引入依赖 + +```xml + + io.agentscope + agentscope-spring-boot-starter + +``` + +### 使用方法 + +直接将带有 `@Tool` 的类声明为 Spring Bean(例如使用 `@Component` 或 `@Service`): + +```java +import org.springframework.stereotype.Service; +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolParam; + +@Service +public class OrderService { + + // 该方法将被自动扫描,并包装为 AgentScope Tool + @Tool(name = "get_order", description = "获取订单详情") + public String getOrder(@ToolParam(description = "订单号") String orderId) { + return "订单 " + orderId + " 的状态为:已发货"; + } +} +``` + +### 配置开关 + +自动扫描功能默认是**开启**的。如果在项目中遇到同名冲突、安全策略限制或有极端的启动性能要求,可通过 Spring Boot 配置文件(`application.yml` 或 `application.properties`)随时将其关闭: + +```yaml +agentscope: + tool: + auto-scan: + enabled: false # 设置为 false 即可彻底关闭自动扫描 +``` + +### 高级特性支持 + +* **Spring AOP 代理支持**:无论 Bean 是否被 `@Transactional`、`@Async` 或自定义切面增强(CGLIB/JDK 动态代理),框架能精准提取原始目标类上的 `@Tool` 注解,同时在最终执行时完整保留 Spring 切面的拦截逻辑。 +* **接口与父类继承**:支持将 `@Tool` 注解定义在接口(Interface)或父类(Superclass)上,实现类只需保持常规继承关系,同样会被自动扫描和注册。 + +### 注意事项与容错机制 + +* **安全扫描时机**:自动扫描动作挂载在 Spring 容器的所有单例 Bean 初始化完成之后(基于 `SmartInitializingSingleton`),不会导致“提前实例化”引发的依赖循环问题。 +* **同名冲突快速失败(Fail-Fast)**:如果扫描器在不同的 Bean 中发现了相同的 `@Tool(name="...")`,会在应用启动阶段直接抛出 `BeanInitializationException` 并阻断启动,杜绝运行时路由错乱。 +* **`@Lazy` 延迟加载提醒**:如果一个被标记为 `@Lazy` 的 Bean 内部包含工具方法,框架会强制触发该 Bean 的初始化,并在控制台输出一条 `WARN` 日志提示。 +* **多例(Prototype)与内部 Bean 过滤**:为了防止内存泄漏和状态混乱,扫描器会主动过滤掉多例(`@Scope("prototype")`)及 Spring 内部(Infrastructure)的 Bean。 + ## 工具组 按场景管理工具,支持动态激活/停用: From d4394eefe6827661baf9b04783b7627e2ca47ad3 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Sat, 11 Apr 2026 23:24:49 +0800 Subject: [PATCH 4/5] fix: copilot review --- .../io/agentscope/core/tool/RegisteredToolFunction.java | 8 ++++++++ .../main/java/io/agentscope/core/tool/ToolRegistry.java | 6 +++++- .../spring/boot/AgentscopeAutoConfiguration.java | 2 ++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/RegisteredToolFunction.java b/agentscope-core/src/main/java/io/agentscope/core/tool/RegisteredToolFunction.java index e779e0e93..c972fb5b1 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/RegisteredToolFunction.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/RegisteredToolFunction.java @@ -139,4 +139,12 @@ public Map getExtendedParameters() { } return extendedModel.mergeWithBaseSchema(tool.getParameters()); } + + /** + * Creates a deep copy of this RegisteredToolFunction. + * Ensures mutable states (like presetParameters) are isolated. + */ + public RegisteredToolFunction copy() { + return new RegisteredToolFunction(this.tool, this.extendedModel, this.mcpClientName, this.presetParameters); + } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolRegistry.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolRegistry.java index d5a7b83bf..b2af9dffb 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolRegistry.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolRegistry.java @@ -149,7 +149,11 @@ void copyTo(ToolRegistry target) { String toolName = entry.getKey(); AgentTool tool = entry.getValue(); RegisteredToolFunction registered = registeredTools.get(toolName); - target.registerTool(toolName, tool, registered); + if (registered == null) { + continue; + } + + target.registerTool(toolName, tool, registered.copy()); } } } diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java index 46d16c751..fa3fc03c7 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java @@ -33,6 +33,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Scope; /** @@ -155,6 +156,7 @@ public Toolkit globalAgentscopeToolkit() { * template, ensuring isolated state per agent while inheriting all auto-scanned tools. */ @Bean + @Primary @ConditionalOnProperty(prefix = "agentscope.agent", name = "enabled", havingValue = "true") @ConditionalOnMissingBean(name = "agentscopeToolkit") @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) From 74f7427f707a5c50d498fbf3dce92ffcde6c9f8d Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Sat, 11 Apr 2026 23:34:12 +0800 Subject: [PATCH 5/5] fix: code style --- .../java/io/agentscope/core/tool/RegisteredToolFunction.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/RegisteredToolFunction.java b/agentscope-core/src/main/java/io/agentscope/core/tool/RegisteredToolFunction.java index c972fb5b1..10f6e1b59 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/RegisteredToolFunction.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/RegisteredToolFunction.java @@ -145,6 +145,7 @@ public Map getExtendedParameters() { * Ensures mutable states (like presetParameters) are isolated. */ public RegisteredToolFunction copy() { - return new RegisteredToolFunction(this.tool, this.extendedModel, this.mcpClientName, this.presetParameters); + return new RegisteredToolFunction( + this.tool, this.extendedModel, this.mcpClientName, this.presetParameters); } }