package vip.mate.tool; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.stereotype.Component; import org.springframework.util.ClassUtils; import java.lang.reflect.Method; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.Set; /** * Startup-scanned registry of tools that must run sequentially * ({@link ConcurrencyUnsafe}-annotated). Replaces the previous hardcoded * {@code DEFAULT_UNSAFE_TOOLS} set in {@code ToolExecutionExecutor}. * *

Discovery walks every bean definition and inspects the declared * class's methods for the {@link Tool} + {@link ConcurrencyUnsafe} pair. * Beans are not instantiated by this scan — we only resolve the bean * class name and load it via the class loader, which preserves {@code @Lazy} * semantics and avoids triggering ChatModel / DataSource / MCP-client * construction at registry init.

* *

Tool name resolution mirrors Spring AI's logic: {@code @Tool#name()} * when set, otherwise the method's simple name.

* *

The registry is immutable after {@link #scan()}; the unsafe set is * populated once and consulted on every tool execution. MCP tools are not * scanned (their {@link Tool} annotations live inside the MCP framework, not * on user-visible methods); MCP support is tracked as a follow-up.

*/ @Slf4j @Component public class ToolConcurrencyRegistry { private final ConfigurableApplicationContext applicationContext; /** Populated once at startup; never mutated thereafter. */ private volatile Set unsafeNames = Collections.emptySet(); public ToolConcurrencyRegistry(ConfigurableApplicationContext applicationContext) { this.applicationContext = applicationContext; } @PostConstruct void scan() { Set discovered = new HashSet<>(); ConfigurableListableBeanFactory factory = applicationContext.getBeanFactory(); ClassLoader classLoader = applicationContext.getClassLoader(); for (String beanName : factory.getBeanDefinitionNames()) { Class beanClass = resolveBeanClassWithoutInstantiating(factory, beanName, classLoader); if (beanClass == null) continue; // Unwrap CGLIB subclasses (proxies) so we see user-declared methods. Class userClass = ClassUtils.getUserClass(beanClass); for (Method method : userClass.getDeclaredMethods()) { Tool tool = method.getAnnotation(Tool.class); if (tool == null) continue; ConcurrencyUnsafe unsafe = method.getAnnotation(ConcurrencyUnsafe.class); if (unsafe == null) continue; String toolName = tool.name() != null && !tool.name().isEmpty() ? tool.name() : method.getName(); discovered.add(toolName); log.info("[ToolConcurrencyRegistry] Marked tool '{}' as unsafe ({}#{}): {}", toolName, userClass.getSimpleName(), method.getName(), unsafe.value().isEmpty() ? "no reason given" : unsafe.value()); } } // Keep the legacy hardcoded names so existing deployments without // annotations still see the same behavior. New code should rely on // the @ConcurrencyUnsafe annotation rather than this list. discovered.addAll(Arrays.asList("browser_use", "BrowserUseTool", "write_file", "edit_file")); this.unsafeNames = Collections.unmodifiableSet(discovered); log.info("[ToolConcurrencyRegistry] Concurrency-unsafe tools ({}): {}", unsafeNames.size(), unsafeNames); } /** * Resolve a bean's class without instantiating it. * Preference order: *
    *
  1. {@link BeanDefinition#getBeanClassName()} → {@link Class#forName} via the context class loader * (works for stereotype-scanned components).
  2. *
  3. {@code factory.getType(beanName, false)} as a fallback for * {@code @Bean}-defined or programmatically registered beans. * The {@code false} flag forbids FactoryBean initialization.
  4. *
* Returns {@code null} when neither path yields a class — for example, * lambda-defined beans without a resolvable class name. */ private static Class resolveBeanClassWithoutInstantiating(ConfigurableListableBeanFactory factory, String beanName, ClassLoader classLoader) { try { BeanDefinition bd = factory.getBeanDefinition(beanName); String className = bd.getBeanClassName(); if (className != null && !className.isEmpty()) { try { return Class.forName(className, false, classLoader); } catch (ClassNotFoundException | LinkageError ignored) { // Fall through to factory.getType fallback. } } } catch (Exception ignored) { // No bean definition (singleton registered programmatically); fall through. } try { return factory.getType(beanName, false); } catch (Exception ignored) { return null; } } /** {@code true} when the named tool must execute alone (no parallelism). */ public boolean isUnsafe(String toolName) { return toolName != null && unsafeNames.contains(toolName); } /** Defensive copy for diagnostics / admin endpoints. */ public Set snapshot() { return unsafeNames; } }