package vip.mate.tool; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.ApplicationContext; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import vip.mate.tool.model.ToolEntity; import vip.mate.tool.repository.ToolMapper; import org.springframework.ai.support.ToolCallbacks; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallbackProvider; import vip.mate.agent.AgentToolSet; import vip.mate.i18n.I18nService; import vip.mate.i18n.LocaleAwareToolCallback; import java.util.ArrayList; import java.util.Collections; import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Supplier; import java.util.stream.Collectors; /** * 工具注册中心 * 管理所有可供 Agent 使用的工具(内置 + 自定义) * 工具启用状态由数据库 mate_tool 表的 enabled 字段控制 * * @author MateClaw Team */ @Slf4j @Component @RequiredArgsConstructor public class ToolRegistry { private final ApplicationContext applicationContext; private final ToolMapper toolMapper; private final I18nService i18nService; // ==================== Plugin Tools ==================== /** Plugin-registered tool entries with lazy availability checks */ private final CopyOnWriteArrayList pluginTools = new CopyOnWriteArrayList<>(); private final Object enabledToolSetLock = new Object(); private volatile AgentToolSet enabledToolSetCache; /** A tool entry registered by a plugin */ public record PluginToolEntry(ToolCallback callback, Supplier availabilityCheck) {} /** * Register a tool from a plugin with an availability check. * The check is evaluated lazily each time the tool set is built. */ public void registerPluginTool(ToolCallback callback, Supplier availabilityCheck) { pluginTools.add(new PluginToolEntry(callback, availabilityCheck != null ? availabilityCheck : () -> true)); invalidateEnabledToolSetCache("plugin-tool-registered:" + callback.getToolDefinition().name()); log.info("Plugin tool registered: {}", callback.getToolDefinition().name()); } /** * Unregister a plugin tool by name. */ public void unregisterPluginTool(String toolName) { pluginTools.removeIf(entry -> entry.callback().getToolDefinition().name().equals(toolName)); invalidateEnabledToolSetCache("plugin-tool-unregistered:" + toolName); log.info("Plugin tool unregistered: {}", toolName); } /** * Snapshot plugin callbacks that are currently available to the runtime. * Used by the agent tool picker so plugin tools can be bound per agent * through the same {@code mate_agent_tool.tool_name} path as other tools. */ public List listAvailablePluginTools() { List out = new ArrayList<>(); for (PluginToolEntry entry : pluginTools) { try { if (entry.callback() != null && Boolean.TRUE.equals(entry.availabilityCheck().get())) { out.add(entry.callback()); } } catch (Exception e) { String name = entry.callback() != null && entry.callback().getToolDefinition() != null ? entry.callback().getToolDefinition().name() : ""; log.warn("Plugin tool availability check failed for {}: {}", name, e.getMessage()); } } return List.copyOf(out); } public void invalidateEnabledToolSetCache(String reason) { enabledToolSetCache = null; log.debug("Enabled AgentToolSet cache invalidated: {}", reason); } @EventListener(ApplicationReadyEvent.class) public void prewarmEnabledToolSetCache() { CompletableFuture.runAsync(() -> { try { getEnabledToolSet(); } catch (Exception e) { log.debug("Enabled AgentToolSet prewarm skipped: {}", e.getMessage()); } }); } @EventListener public void onMcpServerChanged(vip.mate.tool.mcp.event.McpServerChangedEvent event) { invalidateEnabledToolSetCache("mcp-server-changed:" + event.reason()); prewarmEnabledToolSetCache(); } @EventListener public void onMcpConnectionLost(vip.mate.tool.mcp.event.McpConnectionLostEvent event) { invalidateEnabledToolSetCache("mcp-connection-lost:" + event.serverId()); } /** * 获取所有已启用的工具 Bean(Spring AI @Tool 注解方式) * 通过数据库 enabled 标志过滤,确保 UI 开关真正生效 */ public List getEnabledTools() { return List.copyOf(getEnabledToolBeansByName().values()); } /** * Iterate Spring beans once, returning a {@code beanName → bean} map of every * currently-enabled @Tool bean. *

* This is the single source of truth for "which @Tool beans should the agent see"; both * {@link #getEnabledTools()} and {@link #getEnabledToolSet()} build on it. Returning * {@link LinkedHashMap} preserves the discovery order from {@code getBeansWithAnnotation}, * which {@link AgentToolSet} relies on (built-in tools first, MCP tools second). */ private LinkedHashMap getEnabledToolBeansByName() { return getToolBeansByName(true); } /** * Iterate Spring beans once, returning a {@code beanName → bean} map of * every @Tool bean. When {@code enabledOnly} is true, DB rows with * {@code enabled=false} are excluded; when false, disabled rows are kept for * admin metadata use cases where the UI still needs to resolve aliases. */ private LinkedHashMap getToolBeansByName(boolean enabledOnly) { // 1. 从数据库获取明确禁用的 beanName 黑名单 // 逻辑:只有 DB 中存在记录且 enabled=false 的才跳过 // DB 中没有记录的 bean 默认启用(向后兼容 + 新工具自动可用) Set disabledBeanNames = enabledOnly ? toolMapper.selectList( new LambdaQueryWrapper() .eq(ToolEntity::getEnabled, false) .isNotNull(ToolEntity::getBeanName) ).stream() .map(ToolEntity::getBeanName) .collect(Collectors.toSet()) : Set.of(); LinkedHashMap enabled = new LinkedHashMap<>(); // 2. 扫描 Spring 容器中所有带 @Tool 方法的 Bean Map beans = applicationContext.getBeansWithAnnotation(Component.class); for (Map.Entry entry : beans.entrySet()) { String beanName = entry.getKey(); Object bean = entry.getValue(); boolean hasToolMethod = java.util.Arrays.stream(bean.getClass().getMethods()) .anyMatch(m -> m.isAnnotationPresent(Tool.class)); if (!hasToolMethod) { continue; } // 3. 只有 DB 中明确 enabled=false 的才跳过,其余全部启用 if (disabledBeanNames.contains(beanName)) { log.debug("Skipped disabled tool bean: {} (beanName={})", bean.getClass().getSimpleName(), beanName); } else { enabled.put(beanName, bean); log.debug("Registered tool bean: {} (beanName={})", bean.getClass().getSimpleName(), beanName); } } log.info("Total {} tools: {}", enabledOnly ? "enabled" : "registered", enabled.size()); return enabled; } /** * Build an alias index for every registered {@code @Tool} bean, including * rows disabled in DB. This is for admin display only; runtime tool * exposure must continue to use {@link #getEnabledToolSet()}. */ public AgentToolSet getAllToolBeanSetForAdmin() { LinkedHashMap beansByName = getToolBeansByName(false); List toolBeans = new ArrayList<>(beansByName.values()); IdentityHashMap nameByBean = new IdentityHashMap<>(); for (Map.Entry e : beansByName.entrySet()) { nameByBean.put(e.getValue(), e.getKey()); } List callbacks = new ArrayList<>(); for (Object bean : toolBeans) { Collections.addAll(callbacks, ToolCallbacks.from(bean)); } return AgentToolSet.fromCallbacks(toolBeans, callbacks, nameByBean::get); } /** * Resolve aliases for currently enabled built-in {@code @Tool} beans without * touching {@link ToolCallbackProvider}s. This is intentionally narrower than * {@link #getEnabledToolSet()}: disclosure-tier snapshots only need to bridge * {@code mate_tool.name}/{@code bean_name} onto built-in function names, and * calling providers here would synchronously enumerate MCP tools on the chat * hot path. */ public Set enabledToolBeanFunctionNamesFor(Set aliases) { if (aliases == null || aliases.isEmpty()) { return Set.of(); } Map> index = enabledToolBeanFunctionNameIndex(); LinkedHashSet out = new LinkedHashSet<>(); for (String alias : aliases) { Set hits = index.get(alias); if (hits != null) { out.addAll(hits); } } return out; } /** * Build {@code alias -> @Tool function names} for enabled built-in tool beans. * The aliases mirror {@link AgentToolSet}: function name, Spring bean name, * and Java simple class name. Provider/MCP callbacks are deliberately absent. */ public Map> enabledToolBeanFunctionNameIndex() { LinkedHashMap beansByName = getEnabledToolBeansByName(); Map> index = new LinkedHashMap<>(); for (Map.Entry entry : beansByName.entrySet()) { String beanName = entry.getKey(); Object bean = entry.getValue(); ToolCallback[] callbacks = ToolCallbacks.from(bean); LinkedHashSet functionNames = new LinkedHashSet<>(); for (ToolCallback cb : callbacks) { if (cb != null && cb.getToolDefinition() != null) { functionNames.add(cb.getToolDefinition().name()); } } if (functionNames.isEmpty()) { continue; } putAlias(index, beanName, functionNames); putAlias(index, bean.getClass().getSimpleName(), functionNames); for (String functionName : functionNames) { putAlias(index, functionName, Set.of(functionName)); } } return index; } private static void putAlias(Map> index, String alias, Set functionNames) { if (alias == null || alias.isBlank() || functionNames == null || functionNames.isEmpty()) { return; } index.computeIfAbsent(alias, ignored -> new LinkedHashSet<>()).addAll(functionNames); } /** * 获取统一的 AgentToolSet(包含 @Tool Bean + ToolCallbackProvider) *

* 同时收集: * 1. 当前启用的 @Tool bean * 2. 当前容器中所有 ToolCallbackProvider(MCP server 等) */ public AgentToolSet getEnabledToolSet() { AgentToolSet cached = enabledToolSetCache; if (cached != null) { return cached; } synchronized (enabledToolSetLock) { cached = enabledToolSetCache; if (cached != null) { return cached; } AgentToolSet built = buildEnabledToolSet(); enabledToolSetCache = built; return built; } } private AgentToolSet buildEnabledToolSet() { // Build both the bean list and the identity-based name lookup in one pass — the // latter lets AgentToolSet's alias index resolve a saved binding like // "BrowserUseTool" or "browserUseTool" back to the same callback as "browser_use". LinkedHashMap beansByName = getEnabledToolBeansByName(); List toolBeans = new ArrayList<>(beansByName.values()); IdentityHashMap nameByBean = new IdentityHashMap<>(); for (Map.Entry e : beansByName.entrySet()) { nameByBean.put(e.getValue(), e.getKey()); } Map providerBeans = applicationContext.getBeansOfType(ToolCallbackProvider.class); List providers = new ArrayList<>(providerBeans.values()); // 对内置工具 callback 应用 i18n 描述包装 List localizedCallbacks = new ArrayList<>(); for (Object bean : toolBeans) { ToolCallback[] cbs = ToolCallbacks.from(bean); for (ToolCallback cb : cbs) { String toolName = cb.getToolDefinition().name(); String descKey = "tool." + toolName + ".desc"; // The i18n description is an optional override: tools without a // bundle entry (e.g. wiki tools) keep the description declared on // their @Tool annotation. Use msgOptional so an absent key is not // logged as a "missing key" — that is expected, not a fault. String localizedDesc = i18nService.msgOptional(descKey); if (localizedDesc != null) { localizedCallbacks.add(new LocaleAwareToolCallback(cb, localizedDesc)); } else { localizedCallbacks.add(cb); } } } // MCP provider callbacks 不做 i18n 包装(MCP 工具自行管理描述) for (ToolCallbackProvider provider : providers) { ToolCallback[] cbs = provider.getToolCallbacks(); if (cbs != null) { Collections.addAll(localizedCallbacks, cbs); } } // Plugin tool callbacks — evaluate availability checks lazily int pluginToolCount = 0; for (PluginToolEntry entry : pluginTools) { try { if (Boolean.TRUE.equals(entry.availabilityCheck().get())) { localizedCallbacks.add(entry.callback()); pluginToolCount++; } else { log.debug("Plugin tool excluded (availability check failed): {}", entry.callback().getToolDefinition().name()); } } catch (Exception e) { log.warn("Plugin tool availability check failed for {}: {}", entry.callback().getToolDefinition().name(), e.getMessage()); } } log.info("Building AgentToolSet: toolBeans={}, providers={}, pluginTools={}, totalCallbacks={}", toolBeans.size(), providers.size(), pluginToolCount, localizedCallbacks.size()); return AgentToolSet.fromCallbacks(toolBeans, localizedCallbacks, nameByBean::get); } /** * Returns every runtime identifier by which a currently-enabled tool can be * referenced — SKILL.md authors use all three conventions interchangeably: *
    *
  • {@code @Tool} function name (e.g. {@code browser_use}, {@code runSkillScript})
  • *
  • Spring bean name (e.g. {@code browserUseTool}, {@code skillScriptTool})
  • *
  • MCP tool id / plugin tool name (routed via {@code ToolCallbackProvider})
  • *
* Returning the union lets {@link vip.mate.skill.runtime.SkillDependencyChecker} * accept whichever convention a skill happens to declare. */ public Set availableFunctionNames() { return getEnabledToolSet().allNames(); } /** * 获取数据库中的工具配置列表(全部) */ public List listToolEntities() { return toolMapper.selectList(new LambdaQueryWrapper() .orderByDesc(ToolEntity::getBuiltin) .orderByAsc(ToolEntity::getName)); } /** * 获取已启用的工具配置列表 */ public List listEnabledToolEntities() { return toolMapper.selectList(new LambdaQueryWrapper() .eq(ToolEntity::getEnabled, true) .orderByAsc(ToolEntity::getName)); } }