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..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 @@ -139,4 +139,13 @@ 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/ToolMethodInvoker.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolMethodInvoker.java index 5da99f2d6..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 @@ -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,20 @@ 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 +97,9 @@ Mono invokeAsync( .map( r -> converter.convert( - r, extractGenericType(method))) + r, + extractGenericType( + originalMethod))) .onErrorResume(this::handleError)) .onErrorResume(this::handleError); @@ -92,16 +107,23 @@ 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 +131,13 @@ 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/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-core/src/main/java/io/agentscope/core/tool/Toolkit.java b/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java index 981398a96..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 @@ -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,45 @@ 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 +382,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 +418,19 @@ 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 +791,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 +817,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 +1013,8 @@ 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..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 @@ -23,18 +23,22 @@ 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; 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; /** - * 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 +128,78 @@ */ @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 + @Primary @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 +236,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..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 @@ -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,8 @@ 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..67e629984 --- /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; + } + } +} 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..bcfd526f3 --- /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,194 @@ +/* + * 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 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; +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; + +/** + * 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); + + // 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()); + } 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()); + } +} 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..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 @@ -48,9 +48,12 @@ 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"); }); } 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() {} + } +} 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。 + ## 工具组 按场景管理工具,支持动态激活/停用: