) 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);
}
}