mateclaw/mateclaw-server/src/main/java/vip/mate/agent/AgentToolSet.java
matevip 5b24a599ca fix(agent): resolve agent tool bindings by class/bean/function name aliases (#24)
Issue #24: tools selected in the agent binding UI had no effect at runtime.
mate_tool.name stores the Java class name (e.g. "BrowserUseTool") and was
written into mate_agent_tool.tool_name, but AgentToolSet.withAllowedToolsOnly
matched by the @Tool function name (e.g. "browser_use") — so every binding
was silently filtered out.

Fix: AgentToolSet builds an alias index per ToolCallback indexed by every
equivalent identifier — function name, Spring bean name, and Java class
simple name. withAllowedToolsOnly / withDeniedToolsFiltered / excluding
all accept any of these aliases, mirroring how Spring's BeanFactory accepts
bean names + aliases.

ToolRegistry.getEnabledToolSet now threads a bean→beanName resolver into
the new AgentToolSet.fromCallbacks(...) overload. Existing two-arg callers
keep working; tests pass without changes.

Zero data migration: stale mate_agent_tool rows that previously had no
effect now resolve correctly via the class-name alias.
2026-04-27 23:51:48 +08:00

325 lines
13 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vip.mate.agent;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Agent 统一工具集合
* <p>
* 将 @Tool Bean、ToolCallbackProvider、MCP server 暴露的 tool callbacks
* 统一收集为一致的 ToolCallback 列表,供 StateGraph 节点使用。
*
* <h3>Alias index — why one tool has multiple names</h3>
* Each tool can be referenced by several equivalent identifiers:
* <ul>
* <li>{@code @Tool} function name (the runtime truth: {@code cb.getToolDefinition().name()},
* e.g. {@code browser_use})</li>
* <li>Spring bean name (e.g. {@code browserUseTool})</li>
* <li>Java class simple name (e.g. {@code BrowserUseTool} — what the seed data and
* legacy {@code mate_agent_tool.tool_name} bindings happen to store)</li>
* </ul>
* Filtering operations ({@link #withAllowedToolsOnly}, {@link #withDeniedToolsFiltered},
* {@link #excluding}) accept any of these aliases, so callers don't need to know which
* naming convention the persistence layer happens to use. This is the same pattern Spring's
* {@code BeanFactory} uses for bean names + aliases.
*
* @author MateClaw Team
*/
public class AgentToolSet {
private final List<Object> toolBeans;
private final List<ToolCallback> callbacks;
private final Map<String, ToolCallback> callbackByName;
/**
* Alias → callbacks. One alias may resolve to multiple callbacks
* (e.g. a Spring bean name pointing at a class that exposes several {@code @Tool} methods),
* which is why values are sets.
*/
private final Map<String, Set<ToolCallback>> aliasIndex;
private AgentToolSet(List<Object> toolBeans, List<ToolCallback> callbacks,
Function<Object, String> beanNameResolver) {
this.toolBeans = List.copyOf(toolBeans);
// 按工具名去重内置工具在前先添加MCP 工具在后,同名时保留内置工具
// 使用 LinkedHashMap 保证插入顺序,确保内置工具始终排在 MCP 工具前面(影响 LLM 工具选择倾向)
LinkedHashMap<String, ToolCallback> byName = callbacks.stream()
.collect(Collectors.toMap(
cb -> cb.getToolDefinition().name(),
cb -> cb,
(a, b) -> a,
LinkedHashMap::new));
this.callbackByName = byName;
// callbacks 列表也使用去重后的结果,避免 Spring AI ToolCallingChatOptions 校验重名报错
this.callbacks = List.copyOf(byName.values());
this.aliasIndex = buildAliasIndex(this.toolBeans, byName, beanNameResolver);
}
/**
* Internal constructor for {@link #rebuild} — preserves a pre-filtered alias index
* so we don't need {@code beanNameResolver} on every {@code with*} call.
*/
private AgentToolSet(List<Object> toolBeans, List<ToolCallback> callbacks,
Map<String, Set<ToolCallback>> precomputedAliasIndex) {
this.toolBeans = List.copyOf(toolBeans);
LinkedHashMap<String, ToolCallback> byName = callbacks.stream()
.collect(Collectors.toMap(
cb -> cb.getToolDefinition().name(),
cb -> cb,
(a, b) -> a,
LinkedHashMap::new));
this.callbackByName = byName;
this.callbacks = List.copyOf(byName.values());
this.aliasIndex = Map.copyOf(precomputedAliasIndex);
}
/** No-op resolver for callers that don't have access to Spring bean names. */
private static final Function<Object, String> NO_BEAN_NAMES = bean -> null;
/**
* 从预构建的 ToolCallback 列表构建工具集(用于 i18n 等需要包装 callback 的场景)
*/
public static AgentToolSet fromCallbacks(List<Object> toolBeans, List<ToolCallback> callbacks) {
return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), callbacks, NO_BEAN_NAMES);
}
/**
* Same as {@link #fromCallbacks(List, List)} but additionally indexes each tool bean by
* its Spring bean name and Java simple class name, so {@link #withAllowedToolsOnly} accepts
* any of those identifiers (in addition to the {@code @Tool} function name).
*
* @param beanNameResolver lookup from a tool bean instance to its Spring bean name;
* may return {@code null} if the bean has no registered name
*/
public static AgentToolSet fromCallbacks(List<Object> toolBeans, List<ToolCallback> callbacks,
Function<Object, String> beanNameResolver) {
return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), callbacks, beanNameResolver);
}
/**
* 从 @Tool Bean 列表和 ToolCallbackProvider 列表构建统一工具集
*/
public static AgentToolSet from(List<Object> toolBeans, List<ToolCallbackProvider> providers) {
List<ToolCallback> allCallbacks = new ArrayList<>();
// 收集 @Tool Bean 的 callbacks
if (toolBeans != null) {
for (Object bean : toolBeans) {
ToolCallback[] cbs = ToolCallbacks.from(bean);
Collections.addAll(allCallbacks, cbs);
}
}
// 收集 ToolCallbackProvider 的 callbacks
if (providers != null) {
for (ToolCallbackProvider provider : providers) {
ToolCallback[] cbs = provider.getToolCallbacks();
if (cbs != null) {
Collections.addAll(allCallbacks, cbs);
}
}
}
return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), allCallbacks, NO_BEAN_NAMES);
}
/**
* 过滤掉 denied 工具后返回新的 AgentToolSet。
* denied 工具不会暴露给模型,模型完全不知道它们的存在。
*
* @param deniedTools denied 工具名集合(接受 function name / bean name / class simple name
* 为空或 null 时直接返回 this
*/
public AgentToolSet withDeniedToolsFiltered(Set<String> deniedTools) {
if (deniedTools == null || deniedTools.isEmpty()) {
return this;
}
Set<ToolCallback> denied = resolveAliases(deniedTools);
if (denied.isEmpty()) {
return this;
}
List<ToolCallback> filtered = callbacks.stream()
.filter(cb -> !denied.contains(cb))
.toList();
return rebuild(filtered);
}
/**
* 仅保留指定名称的工具(白名单模式,用于 per-agent 绑定)
*
* @param allowedTools 允许的工具名集合(接受 function name / Spring bean name / Java class simple name
* 为 null 时直接返回 this表示使用全局默认
*/
public AgentToolSet withAllowedToolsOnly(Set<String> allowedTools) {
if (allowedTools == null) {
return this; // null = 无绑定,使用全局默认
}
Set<ToolCallback> allowed = resolveAliases(allowedTools);
List<ToolCallback> filtered = callbacks.stream()
.filter(allowed::contains)
.toList();
return rebuild(filtered);
}
/**
* 获取所有 ToolCallback
*/
public List<ToolCallback> callbacks() {
return callbacks;
}
/**
* 获取按名称索引的 ToolCallback Map
*/
public Map<String, ToolCallback> callbackByName() {
return callbackByName;
}
/**
* 获取原始的 @Tool Bean 列表
*/
public List<Object> toolBeans() {
return toolBeans;
}
/**
* 返回排除指定工具名后的新 AgentToolSet
*
* @param toolNames 要排除的工具名集合(接受 function name / bean name / class simple name
*/
public AgentToolSet excluding(Set<String> toolNames) {
if (toolNames == null || toolNames.isEmpty()) {
return this;
}
Set<ToolCallback> excluded = resolveAliases(toolNames);
if (excluded.isEmpty()) {
return this;
}
List<ToolCallback> filtered = callbacks.stream()
.filter(cb -> !excluded.contains(cb))
.toList();
return rebuild(filtered);
}
/**
* 是否为空(无任何工具)
*/
public boolean isEmpty() {
return callbacks.isEmpty();
}
/**
* 工具数量
*/
public int size() {
return callbacks.size();
}
// ==================== Internals ====================
/**
* Resolve a set of aliases (any mix of function name / bean name / class simple name)
* into the set of {@link ToolCallback} instances they refer to. Unknown aliases are
* silently dropped — the caller is expected to be tolerant of stale persistence data.
*/
private Set<ToolCallback> resolveAliases(Set<String> aliases) {
Set<ToolCallback> resolved = new LinkedHashSet<>();
for (String alias : aliases) {
Set<ToolCallback> hits = aliasIndex.get(alias);
if (hits != null) {
resolved.addAll(hits);
}
}
return resolved;
}
/**
* Reconstruct a new {@code AgentToolSet} after filtering callbacks, carrying forward
* only the alias entries whose targets survived. This avoids re-running
* {@link ToolCallbacks#from(Object)} reflection on every {@code with*} call.
*/
private AgentToolSet rebuild(List<ToolCallback> filteredCallbacks) {
Set<ToolCallback> survivors = new HashSet<>(filteredCallbacks);
Map<String, Set<ToolCallback>> filteredAliases = new LinkedHashMap<>();
for (Map.Entry<String, Set<ToolCallback>> e : aliasIndex.entrySet()) {
Set<ToolCallback> kept = new LinkedHashSet<>();
for (ToolCallback cb : e.getValue()) {
if (survivors.contains(cb)) {
kept.add(cb);
}
}
if (!kept.isEmpty()) {
filteredAliases.put(e.getKey(), Set.copyOf(kept));
}
}
return new AgentToolSet(toolBeans, filteredCallbacks, filteredAliases);
}
/**
* Build the alias index. Function names are always indexed (they are the runtime truth);
* bean names and class simple names are indexed when {@code beanNameResolver} is provided
* — typically only the production registry has the {@link org.springframework.context.ApplicationContext}
* needed to map bean instances to names. Unit tests that pass empty {@code toolBeans}
* naturally get a function-name-only index.
*/
private static Map<String, Set<ToolCallback>> buildAliasIndex(
List<Object> toolBeans,
Map<String, ToolCallback> callbackByName,
Function<Object, String> beanNameResolver) {
Map<String, Set<ToolCallback>> aliases = new LinkedHashMap<>();
// 1. Always index by function name (the runtime identifier)
for (Map.Entry<String, ToolCallback> e : callbackByName.entrySet()) {
aliases.computeIfAbsent(e.getKey(), k -> new LinkedHashSet<>()).add(e.getValue());
}
// 2. If we have bean info, also index by Spring bean name and Java class simple name.
// A single bean may expose multiple @Tool methods → the alias maps to a set.
if (beanNameResolver != null) {
for (Object bean : toolBeans) {
String beanName = beanNameResolver.apply(bean);
String simpleName = bean.getClass().getSimpleName();
// Find which callbacks belong to this bean, looking them up in the
// (possibly i18n-wrapped) callbackByName so we point at the same
// instances the rest of the set uses.
Set<ToolCallback> beanCallbacks = new LinkedHashSet<>();
ToolCallback[] rawCallbacks;
try {
rawCallbacks = ToolCallbacks.from(bean);
} catch (Exception ignored) {
// Defensive: a misbehaving bean shouldn't break the whole tool set
continue;
}
for (ToolCallback raw : rawCallbacks) {
ToolCallback wrapped = callbackByName.get(raw.getToolDefinition().name());
if (wrapped != null) {
beanCallbacks.add(wrapped);
}
}
if (beanCallbacks.isEmpty()) {
continue;
}
if (beanName != null && !beanName.isBlank()) {
aliases.computeIfAbsent(beanName, k -> new LinkedHashSet<>()).addAll(beanCallbacks);
}
if (simpleName != null && !simpleName.isBlank()) {
aliases.computeIfAbsent(simpleName, k -> new LinkedHashSet<>()).addAll(beanCallbacks);
}
}
}
// Freeze inner sets
Map<String, Set<ToolCallback>> frozen = new LinkedHashMap<>();
for (Map.Entry<String, Set<ToolCallback>> e : aliases.entrySet()) {
frozen.put(e.getKey(), Set.copyOf(e.getValue()));
}
return Map.copyOf(frozen);
}
}