fix: expose plugin tools in agent picker (#604)

This commit is contained in:
matevip 2026-08-20 02:22:35 -04:00
parent e9b3c1697f
commit 5b3285c78f
6 changed files with 120 additions and 20 deletions

View File

@ -77,6 +77,28 @@ public class ToolRegistry {
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<ToolCallback> listAvailablePluginTools() {
List<ToolCallback> 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()
: "<unknown>";
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);

View File

@ -9,7 +9,8 @@ import lombok.NoArgsConstructor;
* Picker DTO for the unified agent tool selector.
*
* <p>One row per atomic tool the agent can be bound to built-in tools
* appear under {@code source="builtin"}, MCP tools appear under
* appear under {@code source="builtin"}, plugin callbacks appear under
* {@code source="plugin"}, MCP tools appear under
* {@code source="mcp"} and are grouped by their server. The {@link #name}
* field is the value the UI saves into {@code mate_agent_tool.tool_name};
* for MCP tools it is the prefixed callback name returned by the resolver
@ -29,7 +30,7 @@ public class AvailableToolDTO {
*/
private String rowId;
/** {@code "builtin"} or {@code "mcp"}. */
/** {@code "builtin"}, {@code "channel"}, {@code "plugin"}, or {@code "mcp"}. */
private String source;
/** MCP server id when {@code source == "mcp"}; null otherwise. */
@ -123,6 +124,23 @@ public class AvailableToolDTO {
.build();
}
public static AvailableToolDTO fromPlugin(String name, String description) {
return AvailableToolDTO.builder()
.rowId("plugin#" + name)
.source("plugin")
.providerId(null)
.providerName(null)
.name(name)
.rawName(name)
.description(description != null ? description : "")
.group("Plugin tools")
.groupId("plugin")
.stale(false)
.available(true)
.unavailableReason(null)
.build();
}
private static String extractChannelName(String displayName) {
if (displayName == null) return "";
int open = displayName.lastIndexOf('(');

View File

@ -5,7 +5,9 @@ import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.stereotype.Service;
import vip.mate.tool.ToolRegistry;
import vip.mate.tool.mcp.model.McpServerEntity;
import vip.mate.tool.mcp.runtime.McpHashCollisionDetector;
import vip.mate.tool.mcp.service.McpServerService;
@ -19,8 +21,9 @@ import java.util.List;
* Aggregator behind {@code GET /api/v1/tools/available}.
*
* <p>Returns one DTO per atomic tool the agent edit picker can offer:
* built-in tools (from {@link ToolService#listEnabledTools()}) plus every
* MCP tool persisted in {@link McpServerEntity#getToolsCacheJson()}.
* built-in tools (from {@link ToolService#listEnabledTools()}), plugin
* callbacks registered in {@link ToolRegistry}, plus every MCP tool
* persisted in {@link McpServerEntity#getToolsCacheJson()}.
*
* <p>Reads the cache rather than making a live MCP {@code listTools()}
* roundtrip so the picker stays fast and stable through brief upstream
@ -33,17 +36,11 @@ import java.util.List;
* this, the user could save a {@code mate_agent_tool.tool_name} that
* resolves to nothing at chat time.
*
* <p><b>Scope</b>: this aggregator covers the two tool sources users can
* bind from the agent edit screen built-in {@code @Tool} beans
* (persisted in {@code mate_tool}) and MCP-discovered tools (cached on
* the server row). Plugin-registered {@code ToolCallback} beans surfaced
* by other parts of the runtime are intentionally NOT listed here: those
* are not user-bindable from the agent picker today, and the picker's
* "saved name == runtime callback key" contract only needs to hold for
* the rows the picker actually emits. If plugin tools later become
* user-bindable, extend this aggregator (or accept that they go through
* a separate config path) see {@code AgentBindingService}'s
* {@code SYSTEM_LEVEL_TOOLS} carve-out for the same reasoning.
* <p><b>Scope</b>: this aggregator covers the tool sources users can bind
* from the agent edit screen. Every emitted {@code name} must match the
* runtime callback key accepted by {@code AgentToolSet}; otherwise the UI
* could persist a {@code mate_agent_tool.tool_name} row that resolves to
* nothing at chat time.
*/
@Slf4j
@Service
@ -52,10 +49,12 @@ public class AvailableToolService {
private final ToolService toolService;
private final McpServerService mcpServerService;
private final ToolRegistry toolRegistry;
public List<AvailableToolDTO> listAvailable() {
List<AvailableToolDTO> out = new ArrayList<>();
appendBuiltinTools(out);
appendPluginTools(out);
appendMcpTools(out);
return out;
}
@ -74,6 +73,30 @@ public class AvailableToolService {
}
}
private void appendPluginTools(List<AvailableToolDTO> out) {
List<ToolCallback> callbacks;
try {
callbacks = toolRegistry.listAvailablePluginTools();
} catch (Exception e) {
log.warn("AvailableToolService: listAvailable plugin tools failed: {}", e.getMessage());
return;
}
for (ToolCallback callback : callbacks) {
try {
if (callback == null || callback.getToolDefinition() == null) {
continue;
}
String name = callback.getToolDefinition().name();
if (name == null || name.isBlank()) {
continue;
}
out.add(AvailableToolDTO.fromPlugin(name, callback.getToolDefinition().description()));
} catch (Exception e) {
log.warn("AvailableToolService: skipping plugin tool due to: {}", e.getMessage());
}
}
}
private void appendMcpTools(List<AvailableToolDTO> out) {
List<McpServerEntity> servers;
try {

View File

@ -3,6 +3,9 @@ package vip.mate.tool.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import vip.mate.tool.ToolRegistry;
import vip.mate.tool.mcp.model.McpServerEntity;
import vip.mate.tool.mcp.runtime.McpToolNameResolver;
import vip.mate.tool.mcp.service.McpServerService;
@ -26,15 +29,18 @@ class AvailableToolServiceTest {
private ToolService toolService;
private McpServerService mcpServerService;
private ToolRegistry toolRegistry;
private AvailableToolService service;
@BeforeEach
void setUp() {
toolService = mock(ToolService.class);
mcpServerService = mock(McpServerService.class);
service = new AvailableToolService(toolService, mcpServerService);
toolRegistry = mock(ToolRegistry.class);
service = new AvailableToolService(toolService, mcpServerService, toolRegistry);
when(toolService.listEnabledTools()).thenReturn(List.of());
when(mcpServerService.listEnabled()).thenReturn(List.of());
when(toolRegistry.listAvailablePluginTools()).thenReturn(List.of());
}
@Test
@ -50,6 +56,27 @@ class AvailableToolServiceTest {
assertEquals(Set.of("builtin", "mcp"), sources);
}
@Test
@DisplayName("plugin-registered callbacks appear as bindable agent picker tools")
void includesPluginRegisteredCallbacks() {
ToolCallback pluginTool = pluginCallback("custom_invoice_lookup", "Look up invoices from a plugin");
when(toolRegistry.listAvailablePluginTools()).thenReturn(List.of(pluginTool));
List<AvailableToolDTO> out = service.listAvailable();
assertEquals(1, out.size());
AvailableToolDTO dto = out.get(0);
assertEquals("plugin", dto.getSource());
assertEquals("plugin#custom_invoice_lookup", dto.getRowId());
assertEquals("custom_invoice_lookup", dto.getName());
assertEquals("custom_invoice_lookup", dto.getRawName());
assertEquals("Look up invoices from a plugin", dto.getDescription());
assertEquals("Plugin tools", dto.getGroup());
assertEquals("plugin", dto.getGroupId());
assertTrue(dto.isAvailable());
assertFalse(dto.isStale());
}
@Test
@DisplayName("MCP entry name equals McpToolNameResolver.prefixedName(serverId, raw)")
void mcpNameMatchesResolver() {
@ -221,4 +248,14 @@ class AvailableToolServiceTest {
s.setToolsCacheJson(cacheJson);
return s;
}
private static ToolCallback pluginCallback(String name, String description) {
ToolCallback callback = mock(ToolCallback.class);
when(callback.getToolDefinition()).thenReturn(ToolDefinition.builder()
.name(name)
.description(description)
.inputSchema("{}")
.build());
return callback;
}
}

View File

@ -522,8 +522,8 @@ export const toolApi = {
list: () => http.get('/tools'),
listEnabled: () => http.get('/tools/enabled'),
/**
* Unified picker source for the agent edit tool tab returns built-in
* tools plus every MCP-discovered tool grouped by server. The `name`
* Unified picker source for the agent edit tool tab returns built-in,
* channel, plugin, and MCP-discovered tools. The `name`
* field is what gets saved into mate_agent_tool.tool_name.
*/
listAvailable: () => http.get('/tools/available'),

View File

@ -1409,8 +1409,8 @@ async function openEditModal(agent: Agent) {
// RFC-042: /skills is now paginated; binding dropdown only needs enabled skills,
// so listEnabled() is both semantically correct and shape-stable (returns array).
skillApi.listEnabled(),
// /tools/available aggregates built-in tools + every MCP-discovered
// tool grouped by server, with stale/available flags so the picker
// /tools/available aggregates built-in, channel, plugin, and MCP-discovered
// tools, with stale/available flags so the picker
// matches the runtime callback set exactly.
toolApi.listAvailable(),
// Options, not the full provider list: /models is admin-only, and a