diff --git a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java index de320a1c..5362a982 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java @@ -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 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); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java b/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java index 91eaa6c2..bf90d238 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java @@ -9,7 +9,8 @@ import lombok.NoArgsConstructor; * Picker DTO for the unified agent tool selector. * *

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('('); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java b/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java index d21cbd74..7438dd60 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java @@ -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}. * *

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()}. * *

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. * - *

Scope: 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. + *

Scope: 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 listAvailable() { List out = new ArrayList<>(); appendBuiltinTools(out); + appendPluginTools(out); appendMcpTools(out); return out; } @@ -74,6 +73,30 @@ public class AvailableToolService { } } + private void appendPluginTools(List out) { + List 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 out) { List servers; try { diff --git a/mateclaw-server/src/test/java/vip/mate/tool/service/AvailableToolServiceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/service/AvailableToolServiceTest.java index db891774..f358b95f 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/service/AvailableToolServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/service/AvailableToolServiceTest.java @@ -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 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; + } } diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 8ae34460..2acad37b 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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'), diff --git a/mateclaw-ui/src/views/Agents.vue b/mateclaw-ui/src/views/Agents.vue index f2080e70..4b8b4b62 100644 --- a/mateclaw-ui/src/views/Agents.vue +++ b/mateclaw-ui/src/views/Agents.vue @@ -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