diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 34c733db..c373e245 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -43,6 +43,7 @@ import vip.mate.llm.routing.ProviderRouter; import vip.mate.llm.service.ModelConfigService; import vip.mate.llm.service.ModelProviderService; import vip.mate.planning.service.PlanningService; +import vip.mate.skill.runtime.SkillCatalogRenderer; import vip.mate.skill.service.SkillService; import vip.mate.system.service.SystemSettingService; import vip.mate.tool.ToolRegistry; @@ -78,6 +79,12 @@ public class AgentGraphBuilder { private final AgentBindingService agentBindingService; private final SkillService skillService; private final vip.mate.skill.runtime.SkillRuntimeService skillRuntimeService; + private final vip.mate.tool.disclosure.ToolDisclosureService toolDisclosureService; + + /** Escape hatch: when false, the load_skill meta tool is not advertised. */ + @org.springframework.beans.factory.annotation.Value( + "${mateclaw.skill.disclosure.load-skill-tool.enabled:true}") + private boolean loadSkillToolEnabled; private final ConversationService conversationService; private final ModelConfigService modelConfigService; private final ModelProviderService modelProviderService; @@ -179,6 +186,13 @@ public class AgentGraphBuilder { Set boundTools = agentBindingService.getEffectiveToolNames(entity.getId()); toolSet = toolSet.withAllowedToolsOnly(boundTools); // null = 全局默认 + // Escape hatch: drop the load_skill meta tool entirely when disabled, so + // it isn't advertised regardless of binding (the catalog guidance falls + // back to readSkillFile — see SkillRuntimeService). + if (!loadSkillToolEnabled) { + toolSet = toolSet.excluding(java.util.Set.of("load_skill")); + } + // Resolve the base model with the precedence: per-conversation pin > // per-Agent model override > global default. resolveRuntimeBaseModel // looks up enabled-only models and silently degrades an unmatched pin / @@ -275,8 +289,27 @@ public class AgentGraphBuilder { } } - String enhancedPrompt = buildEnhancedPrompt(entity, builtinSearchEnabled, - boundTools, runtimeModel.getMaxInputTokens()); + String enhancedPrompt = buildEnhancedPrompt(entity, builtinSearchEnabled); + + // Runtime skill-catalog renderer — captures this agent's bound skills, + // effective tool allowlist, model window and workspace; invoked each + // turn by the reasoning / step-execution nodes with the skills loaded + // so far this run so load_skill pins float to the top of the catalog. + SkillCatalogRenderer skillCatalogRenderer = buildSkillCatalogRenderer( + entity, boundTools, runtimeModel.getMaxInputTokens()); + + // Extension-tool catalog — only for ReAct. The dynamic tool split runs + // in ReasoningNode; Plan-Execute keeps advertising every tool (it has no + // action node to record enable_tool), so baking the catalog there would + // describe an enable_tool flow that can never take effect. + boolean isPlanExecute = "plan_execute".equals(entity.getAgentType()); + if (!isPlanExecute) { + String extensionCatalog = toolDisclosureService.renderExtensionCatalog( + toolSet, runtimeModel.getMaxInputTokens()); + if (extensionCatalog != null && !extensionCatalog.isBlank()) { + enhancedPrompt = enhancedPrompt + extensionCatalog; + } + } // 当前仅支持 DashScope 和 OpenAI-compatible,其他协议直接拒绝 if (!supportsStateGraph(protocol)) { @@ -287,12 +320,12 @@ public class AgentGraphBuilder { BaseAgent agent; boolean toolCallingEnabled; if ("plan_execute".equals(entity.getAgentType())) { - agent = buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, entity.getId()); + agent = buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer); toolCallingEnabled = true; log.info("Built StateGraph Plan-Execute agent: {} (maxIterations={}, tools={}, protocol={})", entity.getName(), maxIter, toolSet.size(), protocol.getId()); } else { - agent = buildReActAgent(toolSet, runtimeModel, maxIter, entity.getId()); + agent = buildReActAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer); // StateGraph 路径下工具调用由 ActionNode 控制,始终启用 toolCallingEnabled = true; log.info("Built StateGraph ReAct agent: {} (maxIterations={}, tools={}, protocol={})", @@ -351,10 +384,16 @@ public class AgentGraphBuilder { StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, int maxIter, Long agentId) { + return buildReActAgent(toolSet, runtimeModel, maxIter, agentId, null); + } + + StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, + int maxIter, Long agentId, SkillCatalogRenderer skillCatalogRenderer) { ChatModel chatModel = buildRuntimeChatModel(runtimeModel); ChatClient chatClient = ChatClient.create(chatModel); String reasoningEffort = resolveReasoningEffortForModel(runtimeModel); - CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort, runtimeModel, agentId); + CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort, + runtimeModel, agentId, skillCatalogRenderer); return new StateGraphReActAgent(chatClient, conversationService, compiledGraph, chatModel, conversationWindowManager, toolSet); } @@ -365,10 +404,17 @@ public class AgentGraphBuilder { StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, int maxIter, Long agentId) { + return buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, agentId, null); + } + + StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, + int maxIter, Long agentId, + SkillCatalogRenderer skillCatalogRenderer) { ChatModel chatModel = buildRuntimeChatModel(runtimeModel); ChatClient chatClient = ChatClient.create(chatModel); String reasoningEffort = resolveReasoningEffortForModel(runtimeModel); - CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort, runtimeModel, agentId); + CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort, + runtimeModel, agentId, skillCatalogRenderer); return new StateGraphPlanExecuteAgent(chatClient, conversationService, graph, planningService, chatModel, conversationWindowManager, toolSet); } @@ -385,6 +431,13 @@ public class AgentGraphBuilder { CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort, ModelConfigEntity primaryModelConfig, Long agentId) { + return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort, + primaryModelConfig, agentId, null); + } + + CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, + String reasoningEffort, ModelConfigEntity primaryModelConfig, + Long agentId, SkillCatalogRenderer skillCatalogRenderer) { try { List fallbackChain = buildFallbackChain(primaryModelConfig, agentId); NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper( @@ -402,7 +455,7 @@ public class AgentGraphBuilder { executor.setAuditEventService(auditEventService); } PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet); - StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager); + StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager, skillCatalogRenderer); PlanSummaryNode planSummaryNode = new PlanSummaryNode(chatModel, planningService, streamingHelper); DirectAnswerNode directAnswerNode = new DirectAnswerNode(); @@ -487,6 +540,13 @@ public class AgentGraphBuilder { .addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE) + // Skill progressive disclosure — pinned skills loaded this + // run. Registered in BOTH graphs so the read-merge-write in + // ActionNode is not dropped on multi-node merges. + .addStrategy(MateClawStateKeys.LOADED_SKILLS, KeyStrategy.REPLACE) + // Tool progressive disclosure — extensions enabled this run. + // Registered in BOTH graphs for the same merge-safety reason. + .addStrategy(MateClawStateKeys.ENABLED_EXTENSION_TOOLS, KeyStrategy.REPLACE) .build(); // Graph 拓扑: @@ -610,6 +670,13 @@ public class AgentGraphBuilder { CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort, ModelConfigEntity primaryModelConfig, Long agentId) { + return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, + primaryModelConfig, agentId, null); + } + + CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, + String reasoningEffort, ModelConfigEntity primaryModelConfig, + Long agentId, SkillCatalogRenderer skillCatalogRenderer) { try { List fallbackChain = buildFallbackChain(primaryModelConfig, agentId); NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper( @@ -633,7 +700,8 @@ public class AgentGraphBuilder { && ModelFamily.detect(primaryModelConfig.getModelName()).supportsReasoningEffort(); ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort, supportsReasoningEffort, - streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService); + streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService, + skillCatalogRenderer, toolDisclosureService); ActionNode actionNode = new ActionNode(executor, streamTracker); ObservationProcessor observationProcessor = new ObservationProcessor(graphObservationProperties); ObservationNode observationNode = new ObservationNode(observationProcessor, streamTracker); @@ -730,6 +798,13 @@ public class AgentGraphBuilder { .addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE) + // Skill progressive disclosure — pinned skills loaded this + // run. Registered in BOTH graphs so the read-merge-write in + // ActionNode is not dropped on multi-node merges. + .addStrategy(MateClawStateKeys.LOADED_SKILLS, KeyStrategy.REPLACE) + // Tool progressive disclosure — extensions enabled this run. + // Registered in BOTH graphs for the same merge-safety reason. + .addStrategy(MateClawStateKeys.ENABLED_EXTENSION_TOOLS, KeyStrategy.REPLACE) .build(); GoalEvaluationNode goalEvalNode = new GoalEvaluationNode( @@ -1088,8 +1163,7 @@ public class AgentGraphBuilder { // ==================== Prompt 构建 ==================== - private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled, - Set boundTools, Integer maxInputTokens) { + private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled) { // The agent's own systemPrompt encodes its identity (role / goal / // backstory). The memory block from workspace files (AGENTS.md, SOUL.md, // PROFILE.md, MEMORY.md, ...) augments that identity with durable @@ -1111,10 +1185,11 @@ public class AgentGraphBuilder { } String basePrompt = basePromptBuilder.toString(); - // 使用 skill runtime 构建技能增强(per-agent 绑定过滤 + 工作区隔离) - Set boundSkillIds = agentBindingService.getBoundSkillIds(entity.getId()); - String skillEnhancement = skillRuntimeService.buildSkillPromptEnhancement( - boundSkillIds, boundTools, maxInputTokens, entity.getId(), entity.getWorkspaceId()); + // The skill catalog (## Skills) is NOT baked here. It is rendered at + // runtime by the reasoning / step-execution nodes via + // SkillCatalogRenderer so its ordering can react to skills loaded this + // run (load_skill pins). Keeping it out of the baked system prompt also + // keeps the prompt-cache prefix stable across turns. // 工具调用指导 String toolGuidance = """ @@ -1231,7 +1306,22 @@ public class AgentGraphBuilder { // Wiki 知识库上下文注入 String wikiContext = wikiContextService.buildWikiContext(entity.getId()); - return basePrompt + skillEnhancement + toolGuidance + searchGuidance + wikiContext; + return basePrompt + toolGuidance + searchGuidance + wikiContext; + } + + /** + * Build the per-agent {@link SkillCatalogRenderer}. Captures the agent's + * bound skills, effective tool allowlist, model window and workspace once; + * the returned renderer is invoked each turn with the skills loaded so far + * this run so {@code load_skill} pins float to the top of the catalog. + */ + private SkillCatalogRenderer buildSkillCatalogRenderer(AgentEntity entity, Set boundTools, + Integer maxInputTokens) { + Set boundSkillIds = agentBindingService.getBoundSkillIds(entity.getId()); + Long agentId = entity.getId(); + Long workspaceId = entity.getWorkspaceId(); + return loaded -> skillRuntimeService.buildSkillPromptEnhancement( + boundSkillIds, boundTools, maxInputTokens, agentId, workspaceId, loaded); } /** diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentToolSet.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentToolSet.java index 1f606ebe..e247aa4b 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentToolSet.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentToolSet.java @@ -219,6 +219,22 @@ public class AgentToolSet { return callbacks.size(); } + /** + * Resolve a mix of aliases (function name / Spring bean name / Java class simple name) + * to the {@code @Tool} function names they map to. Used to bridge persistence layers + * that key a tool by its class or bean name (e.g. {@code mate_tool.name}) onto the + * runtime callback name ({@code cb.getToolDefinition().name()}). Unknown aliases yield + * nothing. + */ + public Set functionNamesFor(Set aliases) { + if (aliases == null || aliases.isEmpty()) { + return Set.of(); + } + return resolveAliases(aliases).stream() + .map(cb -> cb.getToolDefinition().name()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + // ==================== Internals ==================== /** diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index 383b8462..3e9bdb1a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -544,8 +544,13 @@ public class AgentBindingService implements AgentBindingResolver { // CRUD primitives above — a skill-bound agent must still be able // to locate a fact by keyword instead of reading whole files. "search_workspace_memory", + // Progressive tool disclosure — meta tool that activates an + // extension-tier tool for the rest of the conversation. Must be + // agent-wide so the model can always surface hidden tools. + "enable_tool", // Skill discovery / dispatch — skills are docs, not callables; // these helpers let the LLM read SKILL.md / run scripts. + "load_skill", "readSkillFile", "runSkillScript", "listSkillFiles", diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java index e7f4be7b..e3323202 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java @@ -2,6 +2,8 @@ package vip.mate.agent.graph.node; import com.alibaba.cloud.ai.graph.OverAllState; import com.alibaba.cloud.ai.graph.action.NodeAction; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; @@ -28,6 +30,14 @@ import static vip.mate.agent.graph.state.MateClawStateKeys.*; @Slf4j public class ActionNode implements NodeAction { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** Function name of the explicit skill-load tool, mirrored from SkillLoadTool. */ + private static final String LOAD_SKILL_TOOL = "load_skill"; + + /** Function name of the extension-tool activator, mirrored from EnableExtensionTool. */ + private static final String ENABLE_TOOL = "enable_tool"; + private final ToolExecutionExecutor executor; private final vip.mate.channel.web.ChatStreamTracker streamTracker; @@ -133,6 +143,98 @@ public class ActionNode implements NodeAction { output.forcedToolCall(""); } + // Pin skills the model loaded this run so the next reasoning turn's + // catalog ranks them first and the model stops re-loading the same + // skill it already pulled into message history. Tools cannot mutate + // graph state directly, so the load is detected here from the tool + // calls and merged into LOADED_SKILLS (read-merge-write, REPLACE key). + Set requestedSkills = extractLoadedSkillNames(toolCalls); + if (!requestedSkills.isEmpty()) { + Set merged = new LinkedHashSet<>(accessor.loadedSkills()); + if (merged.addAll(requestedSkills)) { + output.loadedSkills(Set.copyOf(merged)); + } + } + + // Same mechanism for enable_tool: record the activated extension tools so + // ReasoningNode's next turn adds them back to the advertised callbacks. + Set enabledTools = extractEnabledToolNames(toolCalls); + if (!enabledTools.isEmpty()) { + Set merged = new LinkedHashSet<>(accessor.enabledExtensionTools()); + if (merged.addAll(enabledTools)) { + output.enabledExtensionTools(Set.copyOf(merged)); + } + } + return output.build(); } + + /** + * Extract the {@code toolName} argument of every {@code enable_tool} call in + * this batch. Like {@link #extractLoadedSkillNames}, an unknown name is + * harmless: the reasoning-node split only activates names that resolve to an + * extension-tier tool actually in the agent's set. + */ + static Set extractEnabledToolNames(List toolCalls) { + if (toolCalls == null || toolCalls.isEmpty()) { + return Set.of(); + } + Set names = new LinkedHashSet<>(); + for (AssistantMessage.ToolCall tc : toolCalls) { + if (tc == null || !ENABLE_TOOL.equals(tc.name())) { + continue; + } + String name = parseStringArg(tc.arguments(), "toolName", "tool_name", "name"); + if (name != null && !name.isBlank()) { + names.add(name.trim()); + } + } + return names; + } + + /** + * Extract the {@code skillName} argument of every {@code load_skill} call in + * this batch. The names are used only to bias catalog ordering, so an + * unparseable or unknown name is harmless (it simply never matches a + * visible skill) — failures are swallowed rather than aborting the batch. + */ + static Set extractLoadedSkillNames(List toolCalls) { + if (toolCalls == null || toolCalls.isEmpty()) { + return Set.of(); + } + Set names = new LinkedHashSet<>(); + for (AssistantMessage.ToolCall tc : toolCalls) { + if (tc == null || !LOAD_SKILL_TOOL.equals(tc.name())) { + continue; + } + String name = parseStringArg(tc.arguments(), "skillName", "skill_name", "name"); + if (name != null && !name.isBlank()) { + names.add(name.trim()); + } + } + return names; + } + + /** + * Read the first present, non-null string value among {@code keys} from a + * tool-call arguments JSON object. Returns null on malformed JSON or when + * none of the keys are present. + */ + private static String parseStringArg(String argumentsJson, String... keys) { + if (argumentsJson == null || argumentsJson.isBlank()) { + return null; + } + try { + JsonNode node = OBJECT_MAPPER.readTree(argumentsJson); + for (String key : keys) { + JsonNode value = node.get(key); + if (value != null && !value.isNull()) { + return value.asText(); + } + } + return null; + } catch (Exception e) { + return null; + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index 5692b94c..19f826c1 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -102,6 +102,18 @@ public class ReasoningNode implements NodeAction { private final ChatModel chatModel; private final List toolCallbacks; + /** + * Full agent tool set, used for the per-turn disclosure split. Null in the + * legacy {@code (ChatModel, List)} path — that path falls back to + * {@link #toolCallbacks} verbatim with no split. + */ + private final AgentToolSet toolSet; + /** + * Splits tools into core + already-enabled extensions per + * {@code ENABLED_EXTENSION_TOOLS}. Null disables the split (advertise the + * full {@link #toolCallbacks}). + */ + private final vip.mate.tool.disclosure.ToolDisclosureService toolDisclosureService; private final String reasoningEffort; /** * PR-1.2 (RFC-049 L1-B): Whether the bound model's {@code ModelFamily} accepts @@ -116,6 +128,12 @@ public class ReasoningNode implements NodeAction { private final int maxOutputTokens; /** Wiki 相关性注入(可选,null 时跳过) */ private final vip.mate.wiki.service.WikiContextService wikiContextService; + /** + * Renders the {@code ## Skills} catalog each turn so its ordering reacts to + * skills loaded this run (load_skill pins). Null in legacy / test + * constructors — when null, no catalog segment is appended. + */ + private final vip.mate.skill.runtime.SkillCatalogRenderer skillCatalogRenderer; public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort, NodeStreamingChatHelper streamingHelper, @@ -159,8 +177,45 @@ public class ReasoningNode implements NodeAction { ConversationWindowManager conversationWindowManager, ChatStreamTracker streamTracker, int maxOutputTokens, vip.mate.wiki.service.WikiContextService wikiContextService) { + this(chatModel, toolSet, reasoningEffort, supportsReasoningEffort, streamingHelper, + conversationWindowManager, streamTracker, maxOutputTokens, wikiContextService, null); + } + + /** + * Primary constructor with the runtime {@link vip.mate.skill.runtime.SkillCatalogRenderer}. + * The catalog is rendered each turn (ordered by skills loaded this run) + * instead of being baked into the system prompt, so the prompt-cache prefix + * stays stable and load_skill pins float to the top. + */ + public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort, + boolean supportsReasoningEffort, + NodeStreamingChatHelper streamingHelper, + ConversationWindowManager conversationWindowManager, + ChatStreamTracker streamTracker, int maxOutputTokens, + vip.mate.wiki.service.WikiContextService wikiContextService, + vip.mate.skill.runtime.SkillCatalogRenderer skillCatalogRenderer) { + this(chatModel, toolSet, reasoningEffort, supportsReasoningEffort, streamingHelper, + conversationWindowManager, streamTracker, maxOutputTokens, wikiContextService, + skillCatalogRenderer, null); + } + + /** + * Primary constructor with the {@link vip.mate.tool.disclosure.ToolDisclosureService}. + * When non-null, {@code buildChatOptions} advertises only core tools plus + * the extensions enabled this run; when null, the full tool set is advertised. + */ + public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort, + boolean supportsReasoningEffort, + NodeStreamingChatHelper streamingHelper, + ConversationWindowManager conversationWindowManager, + ChatStreamTracker streamTracker, int maxOutputTokens, + vip.mate.wiki.service.WikiContextService wikiContextService, + vip.mate.skill.runtime.SkillCatalogRenderer skillCatalogRenderer, + vip.mate.tool.disclosure.ToolDisclosureService toolDisclosureService) { this.chatModel = chatModel; + this.toolSet = toolSet; this.toolCallbacks = toolSet.callbacks(); + this.toolDisclosureService = toolDisclosureService; this.reasoningEffort = reasoningEffort; this.supportsReasoningEffort = supportsReasoningEffort; this.streamingHelper = streamingHelper; @@ -168,6 +223,7 @@ public class ReasoningNode implements NodeAction { this.streamTracker = streamTracker; this.maxOutputTokens = maxOutputTokens > 0 ? maxOutputTokens : DEFAULT_MAX_OUTPUT_TOKENS; this.wikiContextService = wikiContextService; + this.skillCatalogRenderer = skillCatalogRenderer; } public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort, @@ -192,7 +248,9 @@ public class ReasoningNode implements NodeAction { @Deprecated public ReasoningNode(ChatModel chatModel, List toolCallbacks) { this.chatModel = chatModel; + this.toolSet = null; this.toolCallbacks = toolCallbacks; + this.toolDisclosureService = null; this.reasoningEffort = null; this.supportsReasoningEffort = false; this.streamingHelper = null; @@ -200,6 +258,7 @@ public class ReasoningNode implements NodeAction { this.streamTracker = null; this.maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS; this.wikiContextService = null; + this.skillCatalogRenderer = null; } @Override @@ -350,6 +409,18 @@ public class ReasoningNode implements NodeAction { List nonHistoryPrefix = buildNonHistoryPrefix(systemPrompt, workspaceBasePath, agentIdStr, userMsg, accessor.chatOrigin()); + // Append the runtime-rendered skill catalog as a SEPARATE SystemMessage + // right after the skeleton system prompt. Keeping it out of the baked + // prompt keeps the stable prefix's prompt-cache hash intact, while + // re-rendering each turn lets skills loaded this run (load_skill) pin + // to the top of the catalog. Reused verbatim by the PTL retry branch. + if (skillCatalogRenderer != null) { + String skillCatalog = skillCatalogRenderer.render(accessor.loadedSkills()); + if (skillCatalog != null && !skillCatalog.isBlank()) { + nonHistoryPrefix.add(1, new SystemMessage(skillCatalog)); + } + } + if (conversationWindowManager != null) { // Pass conversationId + workspaceBasePath so oversized older // tool results can be spilled to the workspace spill directory @@ -366,7 +437,15 @@ public class ReasoningNode implements NodeAction { log.info("[ReasoningNode] thinkingLevel={}, effectiveReasoningEffort={}, nodeDefault={}", ThinkingLevelHolder.get(), effectiveReasoning, this.reasoningEffort); - ChatOptions options = buildChatOptions(effectiveReasoning); + // Progressive disclosure: advertise only core tools plus the extensions + // enabled this run, computed fresh each turn from ENABLED_EXTENSION_TOOLS + // so an enable_tool call earlier in this loop takes effect immediately. + // Falls back to the full tool set when no disclosure service is wired. + List activeCallbacks = (toolDisclosureService != null && toolSet != null) + ? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools()).activeCallbacks() + : toolCallbacks; + + ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks); Prompt prompt = new Prompt(promptMessages, options); @@ -376,7 +455,7 @@ public class ReasoningNode implements NodeAction { // PTL compact retry 会再 +1。 int nextLlmCallCount = accessor.llmCallCount() + 1; log.debug("[ReasoningNode] Calling LLM with {} messages, {} tool definitions, iteration {}/{}, llmCallCount={}", - promptMessages.size(), toolCallbacks.size(), + promptMessages.size(), activeCallbacks.size(), accessor.iterationCount(), accessor.maxIterations(), nextLlmCallCount); GraphEventPublisher.GraphEvent phaseEvent = GraphEventPublisher.phase("reasoning", @@ -742,12 +821,12 @@ public class ReasoningNode implements NodeAction { * - AnthropicChatModel → AnthropicChatOptions(支持 extended thinking) * - 其他(OpenAI/DashScope)→ OpenAiChatOptions(支持 reasoningEffort) */ - private ChatOptions buildChatOptions(String effectiveReasoning) { + private ChatOptions buildChatOptions(String effectiveReasoning, List activeCallbacks) { // Anthropic 协议模型(AnthropicChatModel):MiniMax 也用此协议但不支持 thinking if (chatModel instanceof org.springframework.ai.anthropic.AnthropicChatModel anthropicModel) { org.springframework.ai.anthropic.AnthropicChatOptions.Builder builder = org.springframework.ai.anthropic.AnthropicChatOptions.builder() - .toolCallbacks(toolCallbacks) + .toolCallbacks(activeCallbacks) .internalToolExecutionEnabled(false); // 仅对真正的 Claude 模型启用 extended thinking(MiniMax 等走 Anthropic 协议但不支持) @@ -792,7 +871,7 @@ public class ReasoningNode implements NodeAction { effectiveMaxTokens = DASHSCOPE_MAX_OUTPUT_TOKENS; } OpenAiChatOptions.Builder oaiBuilder = OpenAiChatOptions.builder() - .toolCallbacks(toolCallbacks) + .toolCallbacks(activeCallbacks) .maxTokens(effectiveMaxTokens); if (StringUtils.hasText(effectiveReasoning)) { oaiBuilder.reasoningEffort(effectiveReasoning); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java index 89374d64..f43729de 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java @@ -27,6 +27,7 @@ import vip.mate.agent.context.RuntimeContextInjector; import vip.mate.agent.graph.executor.ToolExecutionExecutor; import vip.mate.channel.web.ChatStreamTracker; import vip.mate.planning.service.PlanningService; +import vip.mate.skill.runtime.SkillCatalogRenderer; import java.util.ArrayList; import java.util.List; @@ -60,6 +61,12 @@ public class StepExecutionNode implements NodeAction { private final String reasoningEffort; private final NodeStreamingChatHelper streamingHelper; private final long stepWallClockTimeoutMs; + /** + * Renders the {@code ## Skills} catalog at runtime. Null in legacy / test + * constructors — when null, no catalog segment is appended (the Plan path's + * pre-disclosure behavior of baking it into the system prompt is gone). + */ + private final SkillCatalogRenderer skillCatalogRenderer; /** * Per-step tool-call ceiling, aligned with {@code BaseAgent.MAX_ITERATIONS_HARD_CEILING}. @@ -92,7 +99,20 @@ public class StepExecutionNode implements NodeAction { ConversationWindowManager conversationWindowManager) { this(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager, - STEP_WALL_CLOCK_TIMEOUT_MS); + null, STEP_WALL_CLOCK_TIMEOUT_MS); + } + + /** Production constructor with the runtime skill-catalog renderer. */ + public StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet, + ToolExecutionExecutor executor, + PlanningService planningService, + ChatStreamTracker streamTracker, + String reasoningEffort, NodeStreamingChatHelper streamingHelper, + ConversationWindowManager conversationWindowManager, + SkillCatalogRenderer skillCatalogRenderer) { + this(chatModel, toolSet, executor, planningService, streamTracker, + reasoningEffort, streamingHelper, conversationWindowManager, + skillCatalogRenderer, STEP_WALL_CLOCK_TIMEOUT_MS); } /** Test-friendly overload — production callers use the default timeout. */ @@ -103,6 +123,19 @@ public class StepExecutionNode implements NodeAction { String reasoningEffort, NodeStreamingChatHelper streamingHelper, ConversationWindowManager conversationWindowManager, long stepWallClockTimeoutMs) { + this(chatModel, toolSet, executor, planningService, streamTracker, + reasoningEffort, streamingHelper, conversationWindowManager, + null, stepWallClockTimeoutMs); + } + + StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet, + ToolExecutionExecutor executor, + PlanningService planningService, + ChatStreamTracker streamTracker, + String reasoningEffort, NodeStreamingChatHelper streamingHelper, + ConversationWindowManager conversationWindowManager, + SkillCatalogRenderer skillCatalogRenderer, + long stepWallClockTimeoutMs) { this.chatModel = chatModel; this.toolSet = toolSet; this.executor = executor; @@ -111,6 +144,7 @@ public class StepExecutionNode implements NodeAction { this.conversationWindowManager = conversationWindowManager; this.reasoningEffort = reasoningEffort; this.streamingHelper = streamingHelper; + this.skillCatalogRenderer = skillCatalogRenderer; this.stepWallClockTimeoutMs = stepWallClockTimeoutMs; } @@ -494,6 +528,15 @@ public class StepExecutionNode implements NodeAction { 8. 每一步最多做一个必要的检查和一个必要的执行,不要无意义循环。 """; messages.add(new SystemMessage(enhancedSystemPrompt)); + // Runtime skill catalog (rendered here instead of baked into the system + // prompt). The Plan path never pins per-run loads, so render with an + // empty loaded set — this reproduces the pre-disclosure DB ordering. + if (skillCatalogRenderer != null) { + String skillCatalog = skillCatalogRenderer.render(java.util.Set.of()); + if (skillCatalog != null && !skillCatalog.isBlank()) { + messages.add(new SystemMessage(skillCatalog)); + } + } // 注入运行时上下文(当前时间 + 工作目录 + 发起者上下文) messages.add(new UserMessage( RuntimeContextInjector.buildContextMessage(workspaceBasePath, null, accessor.chatOrigin()))); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java index 0efdb601..0b7751da 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java @@ -228,6 +228,26 @@ public final class MateClawStateAccessor { return state.value(CHAT_ORIGIN).orElse(ChatOrigin.EMPTY); } + // ===== Skill progressive disclosure ===== + + /** + * Skills loaded via {@code load_skill} so far this run. Empty when none + * have been loaded (the common first-iteration case). + */ + @SuppressWarnings("unchecked") + public Set loadedSkills() { + return state.>value(LOADED_SKILLS).orElse(Set.of()); + } + + /** + * Extension tools activated via {@code enable_tool} so far this run. Empty + * when none have been enabled (the common case). + */ + @SuppressWarnings("unchecked") + public Set enabledExtensionTools() { + return state.>value(ENABLED_EXTENSION_TOOLS).orElse(Set.of()); + } + // ===== Token Usage ===== public int promptTokens() { @@ -474,6 +494,16 @@ public final class MateClawStateAccessor { return put(CHAT_ORIGIN, origin); } + // ---- Skill progressive disclosure ---- + public OutputBuilder loadedSkills(Set names) { + return put(LOADED_SKILLS, names); + } + + // ---- Tool progressive disclosure ---- + public OutputBuilder enabledExtensionTools(Set names) { + return put(ENABLED_EXTENSION_TOOLS, names); + } + // ---- Token Usage ---- /** 将本次 LLM 调用的 usage 累加到 state 已有值上 */ diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java index f6fc7ed5..a1c25506 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java @@ -222,4 +222,32 @@ public final class MateClawStateKeys { * workspace context. */ public static final String CHAT_ORIGIN = "chat_origin"; + + // ===== Skill progressive disclosure (REPLACE strategy) ===== + + /** + * Names of skills explicitly loaded via the {@code load_skill} tool during + * this graph run. Stored as a {@code Set} and used to pin recently + * loaded skills to the top of the runtime skill catalog so a multi-iteration + * loop stops re-loading the same skill it already pulled into message + * history. ActionNode reads the prior value and writes back the merged set + * (read-merge-write under the REPLACE strategy). + *

+ * MUST be registered in both the ReAct and Plan-Execute KeyStrategyFactory + * blocks or the framework will drop it on multi-node merges, leaving the + * catalog ranker blind to in-run loads. + */ + public static final String LOADED_SKILLS = "loaded_skills"; + + /** + * Function names of extension-tier tools activated via {@code enable_tool} + * during this run. Stored as a {@code Set}; ReasoningNode adds these + * back to the active tool callbacks on its next turn so an enabled extension + * tool becomes callable within the same ReAct loop. ActionNode reads the + * prior value and writes back the merged set (read-merge-write under REPLACE). + *

+ * MUST be registered in both KeyStrategyFactory blocks (see + * {@link #LOADED_SKILLS}). + */ + public static final String ENABLED_EXTENSION_TOOLS = "enabled_extension_tools"; } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillCatalogRenderer.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillCatalogRenderer.java new file mode 100644 index 00000000..1a587fed --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillCatalogRenderer.java @@ -0,0 +1,28 @@ +package vip.mate.skill.runtime; + +import java.util.Set; + +/** + * Renders the agent-scoped skill catalog segment at runtime so its ordering can + * react to skills loaded during the current graph run. + *

+ * Built once per agent (capturing the agent's bound skills, effective tool + * allowlist, model window and workspace), then invoked each turn by the + * reasoning / step-execution nodes with the set of skills already loaded this + * run. Loaded skills are pinned to the top of the catalog so a multi-iteration + * loop stops re-loading something it already pulled into message history. + */ +@FunctionalInterface +public interface SkillCatalogRenderer { + + /** + * Render the {@code ## Skills} catalog segment. + * + * @param loadedThisRun skill names loaded via {@code load_skill} so far in + * this run; pinned to the top of the catalog. Never + * {@code null} — pass an empty set when nothing loaded. + * @return the catalog markdown, or an empty string when the agent has no + * visible skills. + */ + String render(Set loadedThisRun); +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java index 706d3b0a..edfa3eca 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java @@ -63,6 +63,16 @@ public class SkillRuntimeService { private final AcpSkillBridge acpSkillBridge; private final SkillUsageService usageService; + /** + * Mirrors {@code mateclaw.skill.disclosure.load-skill-tool.enabled}. When + * false the catalog guidance points at {@code readSkillFile} instead of + * {@code load_skill} (which is also unregistered upstream). Field-initialised + * to true so non-Spring unit construction keeps the default behavior. + */ + @org.springframework.beans.factory.annotation.Value( + "${mateclaw.skill.disclosure.load-skill-tool.enabled:true}") + private boolean loadSkillToolEnabled = true; + @Autowired public SkillRuntimeService(SkillService skillService, SkillPackageResolver packageResolver, @@ -368,6 +378,25 @@ public class SkillRuntimeService { Integer maxInputTokens, Long agentId, Long agentWorkspaceId) { + return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, + agentId, agentWorkspaceId, Set.of()); + } + + /** + * Build the skill catalog prompt segment, pinning skills loaded this run to + * the top so a multi-iteration loop stops re-loading the same skill. + * + * @param loadedThisRunNames names of skills loaded via {@code load_skill} + * during the current graph run; sorted to the top + * of the catalog ahead of the usage-history + * signals. Never {@code null}. + */ + public String buildSkillPromptEnhancement(Set boundSkillIds, + Set effectiveToolNames, + Integer maxInputTokens, + Long agentId, + Long agentWorkspaceId, + Set loadedThisRunNames) { List activeSkills; if (boundSkillIds != null) { // Per-agent filter: pick the agent's bound subset from the @@ -427,14 +456,10 @@ public class SkillRuntimeService { // hide new skills behind 40+ existing ones, and the LLM tells the // user "no such skill" minutes after they uploaded it. java.time.LocalDateTime recencyCutoff = java.time.LocalDateTime.now().minus(NEW_SKILL_BOOST_WINDOW); - List sorted = SkillCatalogSorter.sortResolved(visibleSkills, SkillCatalogSort.RECOMMENDED) - .stream() - .sorted(java.util.Comparator - .comparingInt((ResolvedSkill s) -> isRecentlyInstalled(s, recencyCutoff) ? 0 : 1) - .thenComparingInt(s -> recentNames.contains(s.getName()) ? 0 : 1) - .thenComparingInt(s -> frequentNames.contains(s.getName()) ? 0 : 1) - .thenComparing(SkillCatalogSorter.resolvedComparator(SkillCatalogSort.RECOMMENDED))) - .toList(); + Set loadedNames = loadedThisRunNames == null ? Set.of() : loadedThisRunNames; + List sorted = applyCatalogSignals( + SkillCatalogSorter.sortResolved(visibleSkills, SkillCatalogSort.RECOMMENDED), + loadedNames, recentNames, frequentNames, recencyCutoff); List pinned = sorted.stream() .filter(s -> s.getId() != null && boundIds.contains(s.getId())) .toList(); @@ -448,15 +473,29 @@ public class SkillRuntimeService { StringBuilder sb = new StringBuilder(); sb.append("\n\n## Skills\n"); sb.append("This is a compact catalog. If a listed skill matches the task, "); - sb.append("first call `readSkillFile(skillName=, filePath=\"SKILL.md\")` and follow its instructions. "); + if (loadSkillToolEnabled) { + sb.append("first call `load_skill(skillName=)` to pull its SKILL.md into the conversation, "); + sb.append("then follow its instructions. Once loaded, the skill stays available in the conversation — "); + sb.append("do not load it again. "); + } else { + sb.append("first call `readSkillFile(skillName=, filePath=\"SKILL.md\")` to read its instructions, "); + sb.append("then follow them. "); + } sb.append("If none of these skills match, call `listAvailableSkills()` to inspect the broader catalog "); sb.append("(it accepts `keyword=` and `limit=` up to 50 — use them to search by topic "); sb.append("when the default page is truncated). "); sb.append("If the user names a specific skill that isn't in this table, "); - sb.append("call `readSkillFile(skillName=\"\", filePath=\"SKILL.md\")` directly — "); + if (loadSkillToolEnabled) { + sb.append("call `load_skill(skillName=\"\")` directly — "); + } else { + sb.append("call `readSkillFile(skillName=\"\", filePath=\"SKILL.md\")` directly — "); + } sb.append("the catalog above is intentionally compact and doesn't list every active skill. "); sb.append("Skills are documentation packages — calling a skill name as a tool will fail. "); - sb.append("Skills with a `scripts/` directory expose `runSkillScript`; SKILL.md will name the script when needed.\n\n"); + sb.append("To read a skill's reference or script files, use "); + sb.append("`readSkillFile(skillName=, filePath=\"references/...\")`. "); + sb.append("Skills with a `scripts/` directory expose `runSkillScript`; "); + sb.append("SKILL.md will name the script when needed.\n\n"); sb.append("| Skill | Status | Description |\n"); sb.append("|-------|--------|-------------|\n"); for (ResolvedSkill skill : selected) { @@ -490,6 +529,33 @@ public class SkillRuntimeService { return sb.toString(); } + /** + * Apply the catalog ranking signals on top of the RECOMMENDED base order. + * Priority, highest first: loaded this run, freshly installed, recently + * loaded (DB history), frequently loaded (DB history), then the RECOMMENDED + * comparator as the stable tiebreak. + *

+ * Package-private and static so it can be unit-tested without standing up + * the full service. + */ + static List applyCatalogSignals(List recommended, + Set loadedThisRunNames, + Set recentNames, + Set frequentNames, + java.time.LocalDateTime recencyCutoff) { + Set loaded = loadedThisRunNames == null ? Set.of() : loadedThisRunNames; + Set recent = recentNames == null ? Set.of() : recentNames; + Set frequent = frequentNames == null ? Set.of() : frequentNames; + return recommended.stream() + .sorted(java.util.Comparator + .comparingInt((ResolvedSkill s) -> loaded.contains(s.getName()) ? 0 : 1) + .thenComparingInt(s -> isRecentlyInstalled(s, recencyCutoff) ? 0 : 1) + .thenComparingInt(s -> recent.contains(s.getName()) ? 0 : 1) + .thenComparingInt(s -> frequent.contains(s.getName()) ? 0 : 1) + .thenComparing(SkillCatalogSorter.resolvedComparator(SkillCatalogSort.RECOMMENDED))) + .toList(); + } + private static boolean isVisibleWithTools(ResolvedSkill skill, Set effectiveToolNames) { if (effectiveToolNames == null) return true; Set tools = skill.getEffectiveAllowedTools(); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/EnableExtensionTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/EnableExtensionTool.java new file mode 100644 index 00000000..f1b3ad6a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/EnableExtensionTool.java @@ -0,0 +1,78 @@ +package vip.mate.tool.builtin; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.agent.AgentToolSet; +import vip.mate.agent.binding.service.AgentBindingService; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.tool.ToolRegistry; +import vip.mate.tool.disclosure.DisclosureTier; +import vip.mate.tool.disclosure.ToolDisclosureService; + +import java.util.Set; + +/** + * Activates an extension-tier tool for the rest of the conversation. + *

+ * The model calls this after spotting a tool in the {@code ## Extension Tools} + * catalog. The tool only validates and returns a confirmation message — the + * activation is recorded into graph state by the action node (tools cannot + * mutate {@code OverAllState} directly), so the enabled tool's schema becomes + * visible on the next reasoning turn of the same ReAct loop. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class EnableExtensionTool { + + private final ToolRegistry toolRegistry; + private final ToolDisclosureService toolDisclosureService; + private final AgentBindingService agentBindingService; + + @Tool(name = "enable_tool", description = """ + Activate an extension tool for the rest of this conversation. + Use this when the Extension Tools catalog lists a tool you need to call. + + Parameters: + - toolName: The tool's function name exactly as shown in the catalog. + + After enabling, issue the real tool call in your NEXT response — it + becomes callable from then on. Only enable a tool the task actually needs. + """) + public String enableTool( + @ToolParam(description = "Extension tool function name from the catalog") + String toolName, + + @Nullable ToolContext ctx + ) { + if (toolName == null || toolName.isBlank()) { + return "Error: toolName is required. See the Extension Tools catalog for valid names."; + } + // Validate against THIS agent's effective tool set, not the global registry — + // otherwise a tool that exists globally but isn't bound to the agent would be + // reported active yet never appear (the reasoning-node split only activates + // tools in the agent's own set). + AgentToolSet agentSet = toolRegistry.getEnabledToolSet(); + Long agentId = ChatOrigin.from(ctx).agentId(); + if (agentId != null) { + Set effective = agentBindingService.getEffectiveToolNames(agentId); + agentSet = agentSet.withAllowedToolsOnly(effective); // null = no restriction + } + ToolCallback callback = agentSet.callbackByName().get(toolName); + if (callback == null) { + return "Error: Tool '" + toolName + "' is not available to this agent. " + + "Use the exact function name from the Extension Tools catalog."; + } + if (toolDisclosureService.resolveTier(callback) != DisclosureTier.EXTENSION) { + return "Tool '" + toolName + "' is already directly callable — just call it, no need to enable."; + } + log.info("enable_tool: activating extension tool '{}' for agent {}", toolName, agentId); + return "Tool '" + toolName + "' is now active. Issue the call in your next response."; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java new file mode 100644 index 00000000..7574903e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java @@ -0,0 +1,74 @@ +package vip.mate.tool.builtin; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.skill.runtime.model.ResolvedSkill; + +/** + * Explicit skill-load entry point. + *

+ * Pulls a skill package's SKILL.md (or a named sub-file) into the conversation + * as a tool observation. Naming it {@code load_skill} — rather than reusing the + * lower-level {@code readSkillFile} — gives the model a clear "load this skill" + * verb that matches the catalog guidance, and the call is detected by the + * action node to pin the skill at the top of the runtime catalog so the model + * does not reload it on later iterations. + *

+ * The full content is returned as a normal tool result; it flows into message + * history via the standard tool-response path and never mutates the system + * prompt, so the prompt-cache prefix stays stable. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SkillLoadTool { + + private final SkillRuntimeService runtimeService; + private final SkillFileTool skillFileTool; + + @Tool(name = "load_skill", description = """ + Load a skill package's SKILL.md into the conversation. + Call this when a skill in the catalog matches the task. + + Parameters: + - skillName: Skill name exactly as shown in the catalog. + - filePath: Optional sub-file inside the skill (e.g. "references/api.md"). + Omit to load SKILL.md. + + The full content is returned as a tool observation; later turns see it in + message history, so do NOT load the same skill again once it is loaded. + Skills are documentation packages — calling a skill name directly as a + tool will fail; load it first, then follow its instructions. + """) + public String loadSkill( + @ToolParam(description = "Skill name as shown in the catalog") + String skillName, + + @ToolParam(description = "Optional sub-file path inside the skill (e.g. references/api.md)", + required = false) + String filePath, + + @Nullable ToolContext ctx + ) { + if (skillName == null || skillName.isBlank()) { + return "Error: skillName is required. Call listAvailableSkills() to see loadable skills."; + } + ResolvedSkill skill = runtimeService.findActiveSkill(skillName); + if (skill == null) { + log.info("load_skill: skill '{}' not found or not enabled", skillName); + return "Error: Skill '" + skillName + "' not found or not enabled. " + + "Call listAvailableSkills(keyword=\"" + skillName + "\") to find the correct name."; + } + String path = (filePath == null || filePath.isBlank()) ? "SKILL.md" : filePath; + log.info("load_skill: loading skill='{}', path='{}'", skillName, path); + // Delegate to the shared reader: it resolves the skill, paginates large + // sub-files, and records usage. SKILL.md is returned in full by default. + return skillFileTool.readSkillFile(skillName, path, null, null, ctx); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/controller/ToolController.java b/mateclaw-server/src/main/java/vip/mate/tool/controller/ToolController.java index b96b2d4d..b7c15ab4 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/controller/ToolController.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/controller/ToolController.java @@ -5,6 +5,8 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; +import vip.mate.tool.disclosure.DisclosureTier; +import vip.mate.tool.disclosure.ToolDisclosureService; import vip.mate.tool.model.AvailableToolDTO; import vip.mate.tool.model.ToolEntity; import vip.mate.tool.service.AvailableToolService; @@ -12,6 +14,7 @@ import vip.mate.tool.service.ToolService; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import java.util.List; +import java.util.Map; /** * 工具管理接口 @@ -26,6 +29,7 @@ public class ToolController { private final ToolService toolService; private final AvailableToolService availableToolService; + private final ToolDisclosureService toolDisclosureService; @Operation(summary = "获取工具列表") @GetMapping @@ -84,4 +88,25 @@ public class ToolController { public R toggle(@PathVariable Long id, @RequestParam boolean enabled) { return R.ok(toolService.toggleTool(id, enabled)); } + + @Operation(summary = "设置工具披露分级(core / extension)") + @PutMapping("/{id}/disclosure-tier") + @RequireWorkspaceRole("admin") + public R setDisclosureTier(@PathVariable Long id, @RequestBody Map body) { + String tier = body == null ? null : body.get("tier"); + if (!DisclosureTier.isValidToken(tier)) { + return R.fail(400, "tier must be 'core' or 'extension'"); + } + ToolEntity tool = toolService.getTool(id); + String type = tool.getToolType(); + // Only builtin / channel atomic tools are tiered on the row itself; MCP / + // ACP / skill tools are tiered at their owning source. + if (!"builtin".equals(type) && !"channel".equals(type)) { + return R.fail(409, "This tool's tier is decided by its owning server/endpoint/skill. " + + "Modify it there instead."); + } + ToolEntity updated = toolService.setDisclosureTier(id, tier); + toolDisclosureService.invalidate(); + return R.ok(updated); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java new file mode 100644 index 00000000..81120b40 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java @@ -0,0 +1,262 @@ +package vip.mate.tool.disclosure; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import vip.mate.agent.AgentToolSet; +import vip.mate.tool.ToolRegistry; +import vip.mate.tool.mcp.model.McpServerEntity; +import vip.mate.tool.mcp.service.McpServerService; +import vip.mate.tool.model.AvailableToolDTO; +import vip.mate.tool.model.ToolEntity; +import vip.mate.tool.service.AvailableToolService; +import vip.mate.tool.service.ToolService; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Default tier resolver. Tier data is read from {@code mate_tool} and + * {@code mate_mcp_server} and cached in a short-lived snapshot so the per-turn + * {@link #split} does not hit the DB on every reasoning step. The PATCH + * endpoints call {@link #invalidate()} after changing a tier. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DefaultToolDisclosureService implements ToolDisclosureService { + + private static final long CACHE_TTL_MS = 30_000L; + + /** + * Meta-tools that must always stay core: hiding them would make progressive + * disclosure unrecoverable (the model could never call {@code enable_tool} + * to surface anything, nor {@code load_skill} to read a skill). + */ + private static final Set ALWAYS_CORE = Set.of("enable_tool", "load_skill"); + + /** + * Code-level extension defaults for builtin tools that may not yet have a + * {@code mate_tool} row when first resolved. A persisted + * {@code disclosure_tier} always overrides these. + */ + private static final Set BUILTIN_EXTENSION_DEFAULTS = Set.of( + "image_generate", "music_generate", "video_generate", "model3d_generate", "browser_use"); + + private final ToolService toolService; + private final McpServerService mcpServerService; + private final AvailableToolService availableToolService; + private final ToolRegistry toolRegistry; + + @Value("${mateclaw.tools.disclosure.mode:progressive}") + private String disclosureMode; + + private volatile Snapshot snapshot; + + private boolean legacyMode() { + return "legacy".equalsIgnoreCase(disclosureMode); + } + + @Override + public DisclosureTier resolveTier(ToolCallback callback) { + if (callback == null || callback.getToolDefinition() == null) { + return DisclosureTier.CORE; + } + return resolveTierByName(callback.getToolDefinition().name()); + } + + @Override + public DisclosureTier resolveTierByName(String toolName) { + if (toolName == null || toolName.isBlank() || legacyMode()) { + return DisclosureTier.CORE; + } + if (ALWAYS_CORE.contains(toolName)) { + return DisclosureTier.CORE; + } + Snapshot snap = snapshot(); + DisclosureTier dbTier = snap.builtinTierByName.get(toolName); + if (dbTier != null) { + return dbTier; + } + if (BUILTIN_EXTENSION_DEFAULTS.contains(toolName)) { + return DisclosureTier.EXTENSION; + } + Long serverId = snap.mcpToolToServerId.get(toolName); + if (serverId != null) { + return snap.serverTierById.getOrDefault(serverId, DisclosureTier.CORE); + } + // Unknown source (ACP / dynamic-skill / plugin) — keep visible. + return DisclosureTier.CORE; + } + + @Override + public ToolDisclosureSplit split(AgentToolSet baseSet, Set enabledExtensions) { + List all = baseSet == null ? List.of() : baseSet.callbacks(); + if (legacyMode()) { + return new ToolDisclosureSplit(all, List.of()); + } + Set enabled = enabledExtensions == null ? Set.of() : enabledExtensions; + List active = new ArrayList<>(all.size()); + List extensionCatalog = new ArrayList<>(); + for (ToolCallback cb : all) { + if (resolveTier(cb) == DisclosureTier.EXTENSION) { + extensionCatalog.add(cb); + if (enabled.contains(cb.getToolDefinition().name())) { + active.add(cb); + } + } else { + active.add(cb); + } + } + return new ToolDisclosureSplit(active, extensionCatalog); + } + + @Override + public String renderExtensionCatalog(AgentToolSet baseSet, Integer maxInputTokens) { + if (legacyMode() || baseSet == null) { + return ""; + } + List extension = split(baseSet, Set.of()).extensionCatalog(); + if (extension.isEmpty()) { + return ""; + } + int limit = catalogEntryLimit(maxInputTokens); + Snapshot snap = snapshot(); + + StringBuilder sb = new StringBuilder(); + sb.append("\n\n## Extension Tools\n"); + sb.append("These tools are not directly callable yet. To use one, first call "); + sb.append("`enable_tool(toolName=\"\")`, then issue the real tool call in your next response. "); + sb.append("Activation lasts for the rest of this conversation. Only enable a tool when the task needs it.\n\n"); + sb.append("| Tool | Source | Description |\n"); + sb.append("|------|--------|-------------|\n"); + int shown = 0; + for (ToolCallback cb : extension) { + if (shown >= limit) break; + String name = cb.getToolDefinition().name(); + sb.append("| `").append(name).append("` | ") + .append(sourceLabel(name, snap)).append(" | "); + String desc = cb.getToolDefinition().description(); + if (desc != null && !desc.isBlank()) { + String d = desc.length() > 80 ? desc.substring(0, 80) + "..." : desc; + sb.append(d.replace("|", "\\|").replace("\n", " ")); + } + sb.append(" |\n"); + shown++; + } + if (extension.size() > shown) { + sb.append("\nShowing ").append(shown).append(" of ").append(extension.size()) + .append(" extension tools.\n"); + } + return sb.toString(); + } + + @Override + public void invalidate() { + this.snapshot = null; + } + + private String sourceLabel(String toolName, Snapshot snap) { + Long serverId = snap.mcpToolToServerId.get(toolName); + if (serverId != null) { + String serverName = snap.serverNameById.get(serverId); + return serverName != null && !serverName.isBlank() ? "mcp:" + serverName : "mcp"; + } + return "builtin"; + } + + private static int catalogEntryLimit(Integer maxInputTokens) { + if (maxInputTokens == null || maxInputTokens <= 0) return 20; + if (maxInputTokens < 8_000) return 12; + if (maxInputTokens < 32_000) return 25; + return 40; + } + + private Snapshot snapshot() { + Snapshot snap = this.snapshot; + if (snap != null && (System.currentTimeMillis() - snap.builtAtMillis) < CACHE_TTL_MS) { + return snap; + } + Snapshot rebuilt = buildSnapshot(); + this.snapshot = rebuilt; + return rebuilt; + } + + private Snapshot buildSnapshot() { + // resolveTier() queries by the runtime function name (cb.getToolDefinition().name()), + // but mate_tool stores the Java class name (e.g. "ImageGenerateTool") and bean name + // (e.g. "imageGenerateTool"). Bridge both onto the function name(s) via the global + // tool set's alias index so a persisted tier actually reaches the runtime split. + Map builtinTierByName = new LinkedHashMap<>(); + AgentToolSet globalSet = null; + try { + globalSet = toolRegistry.getEnabledToolSet(); + } catch (Exception e) { + log.warn("ToolDisclosureService: global tool set unavailable, tier name bridge disabled: {}", + e.getMessage()); + } + try { + for (ToolEntity t : toolService.listTools()) { + if (t.getName() == null || t.getDisclosureTier() == null || t.getDisclosureTier().isBlank()) { + continue; + } + DisclosureTier tier = DisclosureTier.fromToken(t.getDisclosureTier()); + // Key by the raw stored name too — harmless, and covers rows that already + // store a function name. + builtinTierByName.put(t.getName(), tier); + if (globalSet != null) { + Set aliases = new LinkedHashSet<>(); + aliases.add(t.getName()); + if (t.getBeanName() != null && !t.getBeanName().isBlank()) { + aliases.add(t.getBeanName()); + } + for (String functionName : globalSet.functionNamesFor(aliases)) { + builtinTierByName.put(functionName, tier); + } + } + } + } catch (Exception e) { + log.warn("ToolDisclosureService: failed to read mate_tool tiers, defaulting builtin tools to core: {}", + e.getMessage()); + } + + Map mcpToolToServerId = new LinkedHashMap<>(); + try { + for (AvailableToolDTO d : availableToolService.listAvailable()) { + if ("mcp".equals(d.getSource()) && d.getName() != null && d.getProviderId() != null) { + mcpToolToServerId.put(d.getName(), d.getProviderId()); + } + } + } catch (Exception e) { + log.warn("ToolDisclosureService: failed to map MCP tools to servers: {}", e.getMessage()); + } + + Map serverTierById = new LinkedHashMap<>(); + Map serverNameById = new LinkedHashMap<>(); + try { + for (McpServerEntity s : mcpServerService.listAll()) { + serverTierById.put(s.getId(), DisclosureTier.fromToken(s.getDisclosureTier())); + serverNameById.put(s.getId(), s.getName()); + } + } catch (Exception e) { + log.warn("ToolDisclosureService: failed to read MCP server tiers, defaulting to core: {}", + e.getMessage()); + } + + return new Snapshot(builtinTierByName, mcpToolToServerId, serverTierById, serverNameById, + System.currentTimeMillis()); + } + + private record Snapshot(Map builtinTierByName, + Map mcpToolToServerId, + Map serverTierById, + Map serverNameById, + long builtAtMillis) { + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DisclosureTier.java b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DisclosureTier.java new file mode 100644 index 00000000..c90613bd --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DisclosureTier.java @@ -0,0 +1,36 @@ +package vip.mate.tool.disclosure; + +/** + * Progressive tool disclosure tier. + *

    + *
  • {@link #CORE} — always advertised to the LLM.
  • + *
  • {@link #EXTENSION} — hidden behind the extension-tools catalog until + * the model calls {@code enable_tool}, which activates it for the rest of + * the conversation.
  • + *
+ */ +public enum DisclosureTier { + CORE, + EXTENSION; + + /** Token stored in DB columns / accepted by the PATCH endpoints. */ + public String token() { + return name().toLowerCase(); + } + + /** + * Parse a stored tier token. Anything other than a case-insensitive + * {@code "extension"} maps to {@link #CORE} — including {@code null} / blank, + * so a row whose column was never set is treated as core. + */ + public static DisclosureTier fromToken(String token) { + return token != null && "extension".equalsIgnoreCase(token.trim()) + ? EXTENSION : CORE; + } + + /** True for the two valid tokens, used to validate PATCH input. */ + public static boolean isValidToken(String token) { + return token != null + && ("core".equalsIgnoreCase(token.trim()) || "extension".equalsIgnoreCase(token.trim())); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/disclosure/ToolDisclosureService.java b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/ToolDisclosureService.java new file mode 100644 index 00000000..9ad06d83 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/ToolDisclosureService.java @@ -0,0 +1,51 @@ +package vip.mate.tool.disclosure; + +import org.springframework.ai.tool.ToolCallback; +import vip.mate.agent.AgentToolSet; + +import java.util.List; +import java.util.Set; + +/** + * Splits an agent's tool set into the subset advertised to the LLM up front + * ({@code core} + already-enabled extensions) and the {@code extension} catalog + * that stays behind {@code enable_tool} until the model activates it. + * + *

Tier is resolved per source: builtin / channel atomic tools from + * {@code mate_tool.disclosure_tier}, MCP tools from their owning + * {@code mate_mcp_server.disclosure_tier}. Tools that cannot be classified + * (ACP / dynamic-skill wrapped, plugin tools) default to {@code core} so the + * feature never hides a tool it does not understand. + */ +public interface ToolDisclosureService { + + /** Resolve the tier of a runtime tool callback. */ + DisclosureTier resolveTier(ToolCallback callback); + + /** Resolve the tier of a tool by its function name. */ + DisclosureTier resolveTierByName(String toolName); + + /** + * Split {@code baseSet} into active callbacks (core ∪ enabled extensions) + * and the full extension catalog (every extension tool, enabled or not). + */ + ToolDisclosureSplit split(AgentToolSet baseSet, Set enabledExtensions); + + /** + * Render the {@code ## Extension Tools} system-prompt segment for the + * agent's extension tools, or an empty string when there are none / when + * disclosure is disabled. + */ + String renderExtensionCatalog(AgentToolSet baseSet, Integer maxInputTokens); + + /** Drop the cached tier snapshot so the next resolve re-reads the DB. */ + void invalidate(); + + /** + * Result of {@link #split}: {@code activeCallbacks} go to the LLM now; + * {@code extensionCatalog} is every extension tool (enabled or not), used to + * render the prompt catalog. + */ + record ToolDisclosureSplit(List activeCallbacks, List extensionCatalog) { + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/controller/McpServerController.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/controller/McpServerController.java index 45a890f6..ebe4a1af 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/controller/McpServerController.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/controller/McpServerController.java @@ -31,6 +31,7 @@ import vip.mate.workspace.core.annotation.RequireWorkspaceRole; public class McpServerController { private final McpServerService mcpServerService; + private final vip.mate.tool.disclosure.ToolDisclosureService toolDisclosureService; @Operation(summary = "获取 MCP Server 列表") @GetMapping @@ -78,6 +79,20 @@ public class McpServerController { return R.ok(mcpServerService.sanitize(toggled)); } + @Operation(summary = "设置 MCP Server 披露分级(core / extension),整组工具跟随") + @PutMapping("/{id}/disclosure-tier") + @RequireWorkspaceRole("admin") + public R setDisclosureTier(@PathVariable Long id, + @RequestBody java.util.Map body) { + String tier = body == null ? null : body.get("tier"); + if (!vip.mate.tool.disclosure.DisclosureTier.isValidToken(tier)) { + return R.fail(400, "tier must be 'core' or 'extension'"); + } + McpServerEntity updated = mcpServerService.setDisclosureTier(id, tier); + toolDisclosureService.invalidate(); + return R.ok(mcpServerService.sanitize(updated)); + } + @Operation(summary = "测试 MCP Server 连接") @PostMapping("/{id}/test") @RequireWorkspaceRole("admin") diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java index 2c5fbaf8..1f393d5b 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java @@ -85,6 +85,16 @@ public class McpServerEntity { /** 是否系统内置 */ private Boolean builtin; + /** + * Progressive disclosure tier for the whole server's tool group: + * {@code core} (always advertised) or {@code extension} (hidden behind the + * extension-tools catalog until {@code enable_tool} activates an individual + * tool). Defaults to {@code core} so MCP tools stay directly callable; an + * admin can move a noisy server to {@code extension} to keep it out of every + * agent's tool schema until needed. + */ + private String disclosureTier; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java index 30b90d86..deaad663 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java @@ -152,6 +152,19 @@ public class McpServerService { return entity; } + /** + * Set the disclosure tier ({@code core} / {@code extension}) for the whole + * server's tool group. No reconnect needed — tiering only affects how the + * tools are advertised to the LLM. + */ + public McpServerEntity setDisclosureTier(Long id, String tier) { + McpServerEntity entity = getById(id); + entity.setDisclosureTier(vip.mate.tool.disclosure.DisclosureTier.fromToken(tier).token()); + mcpServerMapper.updateById(entity); + log.info("MCP server disclosure tier set: name={}, tier={}", entity.getName(), entity.getDisclosureTier()); + return entity; + } + // ==================== Runtime Operations ==================== public ConnectionResult testConnection(McpServerEntity entity) { @@ -264,6 +277,9 @@ public class McpServerService { copy.setLastConnectedTime(entity.getLastConnectedTime()); copy.setToolCount(entity.getToolCount()); copy.setBuiltin(entity.getBuiltin()); + // Disclosure tier is not sensitive and the UI relies on it to render the + // per-server core/extension pill — dropping it made the field always null. + copy.setDisclosureTier(entity.getDisclosureTier()); copy.setCreateTime(entity.getCreateTime()); copy.setUpdateTime(entity.getUpdateTime()); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java b/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java index e16a6643..9b3251f5 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java @@ -59,6 +59,15 @@ public class ToolEntity { */ private Long channelId; + /** + * Progressive disclosure tier: {@code core} (always advertised to the LLM) + * or {@code extension} (hidden behind the extension-tools catalog until the + * model calls {@code enable_tool}). Admin override for builtin / channel + * atomic tools; sensible defaults for unset rows live in + * {@code ToolDisclosureService}. + */ + private String disclosureTier; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/service/ToolService.java b/mateclaw-server/src/main/java/vip/mate/tool/service/ToolService.java index c6feb452..544bac8f 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/service/ToolService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/service/ToolService.java @@ -73,4 +73,16 @@ public class ToolService { toolMapper.updateById(tool); return tool; } + + /** + * Set the disclosure tier ({@code core} / {@code extension}) of a builtin or + * channel atomic tool. MCP / ACP / skill tools are tiered at their owning + * source, not here — the controller rejects those before calling this. + */ + public ToolEntity setDisclosureTier(Long id, String tier) { + ToolEntity tool = getTool(id); + tool.setDisclosureTier(vip.mate.tool.disclosure.DisclosureTier.fromToken(tier).token()); + toolMapper.updateById(tool); + return tool; + } } diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index d9d57fd0..8f4dcd58 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -131,11 +131,25 @@ mateclaw: # MCP server 配置已迁移至数据库(mate_mcp_server 表),通过 UI 管理 mcp: enabled: true + tools: + disclosure: + # progressive: extension-tier tools are hidden behind the extension-tools + # catalog until the model calls enable_tool. By default that's the heavy + # generative / browser tools; MCP servers default to core (visible) and + # an admin can move a noisy one to extension per server. + # legacy: advertise every bound tool up front (pre-disclosure behavior). + mode: ${MATECLAW_TOOLS_DISCLOSURE_MODE:progressive} skill: workspace: root: ${user.home}/.mateclaw/skills auto-init: true delete-policy: archive + disclosure: + load-skill-tool: + # When false, the load_skill meta tool is not advertised to agents and + # the skill catalog guidance falls back to readSkillFile. Escape hatch + # for operators who don't want the explicit skill-load entry point. + enabled: ${MATECLAW_SKILL_LOAD_SKILL_TOOL_ENABLED:true} curator: enabled: true cron: "0 0 2 * * *" # daily 02:00 — staggered away from wiki / backup jobs diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V121__tool_disclosure_tier.sql b/mateclaw-server/src/main/resources/db/migration/h2/V121__tool_disclosure_tier.sql new file mode 100644 index 00000000..230efd30 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V121__tool_disclosure_tier.sql @@ -0,0 +1,30 @@ +-- V121__tool_disclosure_tier.sql (H2 dialect) +-- +-- Progressive tool disclosure. Each tool source carries a disclosure tier: +-- core → always advertised to the LLM +-- extension → hidden behind the "extension tools" catalog until the model +-- calls enable_tool, which activates it for the rest of the +-- conversation +-- +-- Tier is stored per source: +-- mate_tool.disclosure_tier — builtin / channel atomic tools (admin +-- override; sensible defaults also live +-- in code so a not-yet-seeded tool is +-- still classified correctly) +-- mate_mcp_server.disclosure_tier — one tier for the whole MCP server's +-- tool group. Defaults to core so MCP +-- tools stay directly callable; an admin +-- can move a noisy server to extension. +-- +-- H2 supports ALTER TABLE ... ADD COLUMN IF NOT EXISTS natively. + +ALTER TABLE mate_tool ADD COLUMN IF NOT EXISTS disclosure_tier VARCHAR(16) DEFAULT 'core'; +ALTER TABLE mate_mcp_server ADD COLUMN IF NOT EXISTS disclosure_tier VARCHAR(16) DEFAULT 'core'; + +-- Seed the heavy generative / browser tools as extension so a customer-service +-- agent bound to dozens of tools doesn't pay their JSON-Schema cost up front. +-- NOTE: mate_tool.name stores the Java class name (not the @Tool function name). +UPDATE mate_tool +SET disclosure_tier = 'extension' +WHERE name IN ('ImageGenerateTool', 'MusicGenerateTool', 'VideoGenerateTool', 'Model3dGenerateTool', 'BrowserUseTool') + AND (disclosure_tier IS NULL OR disclosure_tier = 'core'); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V122__fix_generative_tool_tier_names.sql b/mateclaw-server/src/main/resources/db/migration/h2/V122__fix_generative_tool_tier_names.sql new file mode 100644 index 00000000..9d8a61a8 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V122__fix_generative_tool_tier_names.sql @@ -0,0 +1,13 @@ +-- V122__fix_generative_tool_tier_names.sql (H2 dialect) +-- +-- Corrective migration. The first cut of V121 seeded the generative / browser +-- tools as extension using their @Tool function names (image_generate, ...), +-- but mate_tool.name stores the Java class name (ImageGenerateTool, ...), so the +-- UPDATE matched no rows on databases that ran that early version. Re-apply the +-- seed by class name. Idempotent: only promotes core → extension and leaves any +-- admin-set value untouched. + +UPDATE mate_tool +SET disclosure_tier = 'extension' +WHERE name IN ('ImageGenerateTool', 'MusicGenerateTool', 'VideoGenerateTool', 'Model3dGenerateTool', 'BrowserUseTool') + AND (disclosure_tier IS NULL OR disclosure_tier = 'core'); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V121__tool_disclosure_tier.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V121__tool_disclosure_tier.sql new file mode 100644 index 00000000..cd1b1776 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V121__tool_disclosure_tier.sql @@ -0,0 +1,39 @@ +-- V121__tool_disclosure_tier.sql (MySQL dialect) +-- +-- Mirror of the H2 V121, adapted for MySQL 8.0 — no "IF NOT EXISTS" on +-- ADD COLUMN, so we guard with INFORMATION_SCHEMA + prepared statement to +-- stay idempotent (essential for desktop installs that re-apply migrations). +-- +-- See the H2 file for the column semantics. + +-- 1. mate_tool.disclosure_tier (default 'core') +SET @col_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_tool' + AND COLUMN_NAME = 'disclosure_tier' +); +SET @stmt := IF(@col_exists = 0, + 'ALTER TABLE mate_tool ADD COLUMN disclosure_tier VARCHAR(16) DEFAULT ''core'' COMMENT ''core | extension — progressive disclosure tier''', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; + +-- 2. mate_mcp_server.disclosure_tier (default 'core' — MCP tools stay directly +-- callable; an admin can move a noisy server to extension) +SET @col_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_mcp_server' + AND COLUMN_NAME = 'disclosure_tier' +); +SET @stmt := IF(@col_exists = 0, + 'ALTER TABLE mate_mcp_server ADD COLUMN disclosure_tier VARCHAR(16) DEFAULT ''core'' COMMENT ''core | extension — whole-server disclosure tier''', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; + +-- 3. Seed the heavy generative / browser tools as extension. +-- mate_tool.name stores the Java class name (not the @Tool function name). +UPDATE mate_tool +SET disclosure_tier = 'extension' +WHERE name IN ('ImageGenerateTool', 'MusicGenerateTool', 'VideoGenerateTool', 'Model3dGenerateTool', 'BrowserUseTool') + AND (disclosure_tier IS NULL OR disclosure_tier = 'core'); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V122__fix_generative_tool_tier_names.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V122__fix_generative_tool_tier_names.sql new file mode 100644 index 00000000..75b8d235 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V122__fix_generative_tool_tier_names.sql @@ -0,0 +1,13 @@ +-- V122__fix_generative_tool_tier_names.sql (MySQL dialect) +-- +-- Corrective migration. The first cut of V121 seeded the generative / browser +-- tools as extension using their @Tool function names (image_generate, ...), +-- but mate_tool.name stores the Java class name (ImageGenerateTool, ...), so the +-- UPDATE matched no rows on databases that ran that early version. Re-apply the +-- seed by class name. Idempotent: only promotes core → extension and leaves any +-- admin-set value untouched. + +UPDATE mate_tool +SET disclosure_tier = 'extension' +WHERE name IN ('ImageGenerateTool', 'MusicGenerateTool', 'VideoGenerateTool', 'Model3dGenerateTool', 'BrowserUseTool') + AND (disclosure_tier IS NULL OR disclosure_tier = 'core'); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ActionNodeLoadSkillTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ActionNodeLoadSkillTest.java new file mode 100644 index 00000000..4f5560be --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ActionNodeLoadSkillTest.java @@ -0,0 +1,95 @@ +package vip.mate.agent.graph.node; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link ActionNode#extractLoadedSkillNames} — the load_skill + * detection that feeds the {@code LOADED_SKILLS} catalog pin. + */ +class ActionNodeLoadSkillTest { + + private static AssistantMessage.ToolCall call(String name, String args) { + return new AssistantMessage.ToolCall("id-" + name, "function", name, args); + } + + @Test + @DisplayName("empty / null batch yields no names") + void emptyBatch() { + assertTrue(ActionNode.extractLoadedSkillNames(null).isEmpty()); + assertTrue(ActionNode.extractLoadedSkillNames(List.of()).isEmpty()); + } + + @Test + @DisplayName("non-load_skill calls are ignored") + void nonLoadSkillIgnored() { + List calls = List.of( + call("web_search", "{\"query\":\"x\"}"), + call("read_file", "{\"path\":\"/tmp/a\"}")); + assertTrue(ActionNode.extractLoadedSkillNames(calls).isEmpty()); + } + + @Test + @DisplayName("load_skill skillName arg is extracted") + void extractsSkillName() { + List calls = List.of( + call("load_skill", "{\"skillName\":\"pdf\"}")); + assertEquals(Set.of("pdf"), ActionNode.extractLoadedSkillNames(calls)); + } + + @Test + @DisplayName("multiple load_skill calls collect every name, order preserved") + void multipleLoads() { + List calls = List.of( + call("load_skill", "{\"skillName\":\"pdf\"}"), + call("web_search", "{\"query\":\"x\"}"), + call("load_skill", "{\"skillName\":\"docx\",\"filePath\":\"references/a.md\"}")); + assertEquals(Set.of("pdf", "docx"), ActionNode.extractLoadedSkillNames(calls)); + } + + @Test + @DisplayName("alternate arg keys skill_name / name are accepted") + void alternateKeys() { + assertEquals(Set.of("alpha"), + ActionNode.extractLoadedSkillNames(List.of(call("load_skill", "{\"skill_name\":\"alpha\"}")))); + assertEquals(Set.of("beta"), + ActionNode.extractLoadedSkillNames(List.of(call("load_skill", "{\"name\":\"beta\"}")))); + } + + @Test + @DisplayName("malformed or empty args are skipped without throwing") + void malformedArgsSkipped() { + List calls = List.of( + call("load_skill", "not-json"), + call("load_skill", ""), + call("load_skill", "{\"skillName\":\"\"}"), + call("load_skill", "{\"other\":\"y\"}")); + assertTrue(ActionNode.extractLoadedSkillNames(calls).isEmpty()); + } + + @Test + @DisplayName("enable_tool toolName arg is extracted; non-enable_tool ignored") + void extractsEnabledToolNames() { + List calls = List.of( + call("enable_tool", "{\"toolName\":\"image_generate\"}"), + call("web_search", "{\"query\":\"x\"}"), + call("enable_tool", "{\"tool_name\":\"music_generate\"}")); + assertEquals(Set.of("image_generate", "music_generate"), + ActionNode.extractEnabledToolNames(calls)); + } + + @Test + @DisplayName("enable_tool detection ignores empty batch and load_skill calls") + void enableToolEmptyAndCrossTalk() { + assertTrue(ActionNode.extractEnabledToolNames(null).isEmpty()); + assertTrue(ActionNode.extractEnabledToolNames( + List.of(call("load_skill", "{\"skillName\":\"pdf\"}"))).isEmpty()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/architecture/SkillStateKeyDoubleRegistrationTest.java b/mateclaw-server/src/test/java/vip/mate/architecture/SkillStateKeyDoubleRegistrationTest.java new file mode 100644 index 00000000..b5a85a17 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/architecture/SkillStateKeyDoubleRegistrationTest.java @@ -0,0 +1,62 @@ +package vip.mate.architecture; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Strict double-registration check for the skill progressive-disclosure state + * keys. + * + *

{@link StateKeyRegistrationCoverageTest} only verifies that a key appears + * somewhere in {@code AgentGraphBuilder.java}; it cannot tell apart the ReAct + * and Plan-Execute {@code KeyStrategyFactory} blocks. The {@code CHAT_ORIGIN} + * regression showed that "registered once" is not enough — multi-node merges + * silently drop keys when one graph's factory leaves them out. + * + *

{@code LOADED_SKILLS} is written via read-merge-write in ActionNode, so a + * dropped key would silently disable the load_skill catalog pin. It must appear + * in BOTH factory blocks. + */ +class SkillStateKeyDoubleRegistrationTest { + + private static final String[] SKILL_KEYS = { + "LOADED_SKILLS", + "ENABLED_EXTENSION_TOOLS", + }; + + @Test + void everySkillDisclosureKeyMustAppearAtLeastTwiceInAddStrategyCalls() throws Exception { + Path src = Paths.get("src/main/java/vip/mate/agent/AgentGraphBuilder.java") + .toAbsolutePath(); + if (!Files.exists(src)) { + fail("Cannot find AgentGraphBuilder.java at " + src); + } + String content = Files.readString(src); + + for (String key : SKILL_KEYS) { + Pattern p = Pattern.compile( + "\\.addStrategy\\(\\s*MateClawStateKeys\\." + key + "\\b"); + Matcher m = p.matcher(content); + int count = 0; + while (m.find()) count++; + if (count < 2) { + fail("Skill disclosure state key " + key + " must be registered in BOTH the " + + "ReAct and Plan-Execute KeyStrategyFactory blocks " + + "(found " + count + " addStrategy occurrence(s) in " + + "AgentGraphBuilder.java). Without double registration the " + + "spring-ai-alibaba-graph merge can drop the key, silently " + + "disabling the load_skill catalog pin."); + } + assertTrue(count >= 2, + "Sanity: " + key + " should have >=2 addStrategy calls"); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServiceLoadedPinTest.java b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServiceLoadedPinTest.java new file mode 100644 index 00000000..448f9e49 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServiceLoadedPinTest.java @@ -0,0 +1,90 @@ +package vip.mate.skill.runtime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.skill.acp.AcpSkillBridge; +import vip.mate.skill.lessons.SkillLessonsService; +import vip.mate.skill.mcp.McpSkillBridge; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.runtime.model.ResolvedSkill; +import vip.mate.skill.service.SkillService; +import vip.mate.skill.usage.SkillUsageService; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies that skills loaded this run (the {@code LOADED_SKILLS} signal) are + * pinned to the top of the runtime catalog so a multi-iteration loop stops + * re-loading the same skill. + */ +class SkillRuntimeServiceLoadedPinTest { + + @Test + @DisplayName("a skill loaded this run floats to the top, ahead of the budget-truncated default order") + void loadedSkillIsPinnedToTop() { + SkillService skillService = mock(SkillService.class); + SkillPackageResolver resolver = mock(SkillPackageResolver.class); + SkillLessonsService lessonsService = mock(SkillLessonsService.class); + McpSkillBridge mcpBridge = mock(McpSkillBridge.class); + AcpSkillBridge acpBridge = mock(AcpSkillBridge.class); + SkillUsageService usageService = mock(SkillUsageService.class); + + List entities = java.util.stream.IntStream.rangeClosed(1, 12) + .mapToObj(i -> entity((long) i, "skill-%02d".formatted(i), "builtin")) + .toList(); + when(skillService.listEnabledSkills()).thenReturn(entities); + for (SkillEntity entity : entities) { + when(resolver.resolve(entity)).thenReturn(resolved(entity)); + } + when(mcpBridge.listMcpDerivedResolvedSkills()).thenReturn(List.of()); + when(acpBridge.listAcpDerivedResolvedSkills()).thenReturn(List.of()); + when(usageService.recentLoadedSkillNames(null, 8)).thenReturn(Set.of()); + when(usageService.frequentlyLoadedSkillNames(8)).thenReturn(Set.of()); + + SkillRuntimeService runtime = new SkillRuntimeService( + skillService, resolver, lessonsService, mcpBridge, acpBridge, usageService); + + // Control: without the pin, skill-10 is past the 8-entry budget and not shown. + String baseline = runtime.buildSkillPromptEnhancement(null, null, 8192); + assertFalse(baseline.contains("skill-10"), + "control: skill-10 should be truncated out of the default 8-of-12 catalog"); + + // With skill-10 loaded this run, it should be pinned to the top. + String pinned = runtime.buildSkillPromptEnhancement( + null, null, 8192, null, null, Set.of("skill-10")); + + assertTrue(pinned.contains("skill-10"), + "skill loaded this run must appear in the catalog even past the budget; prompt was: " + pinned); + assertTrue(pinned.indexOf("skill-10") < pinned.indexOf("skill-01"), + "skill loaded this run must be pinned ahead of the default-order entries; prompt was: " + pinned); + } + + private static ResolvedSkill resolved(SkillEntity entity) { + return ResolvedSkill.builder() + .id(entity.getId()) + .name(entity.getName()) + .description(entity.getDescription()) + .enabled(Boolean.TRUE.equals(entity.getEnabled())) + .runtimeAvailable(true) + .dependencyReady(true) + .securityBlocked(false) + .build(); + } + + private static SkillEntity entity(Long id, String name, String type) { + SkillEntity entity = new SkillEntity(); + entity.setId(id); + entity.setName(name); + entity.setDescription("Description for " + name); + entity.setSkillType(type); + entity.setEnabled(true); + entity.setSecurityScanStatus("PASSED"); + return entity; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/EnableExtensionToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/EnableExtensionToolTest.java new file mode 100644 index 00000000..a4f2a5e4 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/EnableExtensionToolTest.java @@ -0,0 +1,80 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.annotation.Tool; +import vip.mate.agent.AgentToolSet; +import vip.mate.agent.binding.service.AgentBindingService; +import vip.mate.tool.ToolRegistry; +import vip.mate.tool.disclosure.DisclosureTier; +import vip.mate.tool.disclosure.ToolDisclosureService; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class EnableExtensionToolTest { + + static class Tools { + @Tool(description = "text to image") + public String image_generate() { return ""; } + + @Tool(description = "a plain core tool") + public String my_core_tool() { return ""; } + } + + private static AgentToolSet toolSet() { + return AgentToolSet.fromCallbacks(List.of(new Tools()), List.of(ToolCallbacks.from(new Tools()))); + } + + @Test + @DisplayName("blank toolName returns a required-arg error") + void blankRejected() { + EnableExtensionTool tool = new EnableExtensionTool(mock(ToolRegistry.class), + mock(ToolDisclosureService.class), mock(AgentBindingService.class)); + String out = tool.enableTool(" ", null); + assertTrue(out.startsWith("Error:")); + assertTrue(out.contains("required")); + } + + @Test + @DisplayName("tool not in the agent's set returns an availability error") + void unknownReturnsNotAvailable() { + ToolRegistry registry = mock(ToolRegistry.class); + when(registry.getEnabledToolSet()).thenReturn(toolSet()); + EnableExtensionTool tool = new EnableExtensionTool(registry, + mock(ToolDisclosureService.class), mock(AgentBindingService.class)); + // ctx=null → no agentId → agent set falls back to the full set; "nope" still absent + String out = tool.enableTool("nope", null); + assertTrue(out.startsWith("Error:")); + assertTrue(out.contains("not available")); + } + + @Test + @DisplayName("core-tier tool reports it is already callable") + void coreToolAlreadyCallable() { + ToolRegistry registry = mock(ToolRegistry.class); + when(registry.getEnabledToolSet()).thenReturn(toolSet()); + ToolDisclosureService disclosure = mock(ToolDisclosureService.class); + when(disclosure.resolveTier(any())).thenReturn(DisclosureTier.CORE); + EnableExtensionTool tool = new EnableExtensionTool(registry, disclosure, mock(AgentBindingService.class)); + String out = tool.enableTool("my_core_tool", null); + assertTrue(out.contains("already directly callable")); + } + + @Test + @DisplayName("extension-tier tool is activated") + void extensionToolActivated() { + ToolRegistry registry = mock(ToolRegistry.class); + when(registry.getEnabledToolSet()).thenReturn(toolSet()); + ToolDisclosureService disclosure = mock(ToolDisclosureService.class); + when(disclosure.resolveTier(any())).thenReturn(DisclosureTier.EXTENSION); + EnableExtensionTool tool = new EnableExtensionTool(registry, disclosure, mock(AgentBindingService.class)); + String out = tool.enableTool("image_generate", null); + assertTrue(out.contains("now active")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillLoadToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillLoadToolTest.java new file mode 100644 index 00000000..e4f03ae1 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillLoadToolTest.java @@ -0,0 +1,84 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.skill.runtime.model.ResolvedSkill; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SkillLoadToolTest { + + private static ResolvedSkill skill(String name) { + return ResolvedSkill.builder().id((long) name.hashCode()).name(name).build(); + } + + @Test + @DisplayName("blank skillName returns a friendly required-arg error and does not read") + void blankSkillNameRejected() { + SkillRuntimeService runtime = mock(SkillRuntimeService.class); + SkillFileTool fileTool = mock(SkillFileTool.class); + SkillLoadTool tool = new SkillLoadTool(runtime, fileTool); + + String out = tool.loadSkill(" ", null, null); + + assertTrue(out.startsWith("Error:")); + assertTrue(out.contains("required")); + verify(fileTool, never()).readSkillFile(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("unknown skill returns not-found error with a listAvailableSkills hint") + void unknownSkillReturnsError() { + SkillRuntimeService runtime = mock(SkillRuntimeService.class); + SkillFileTool fileTool = mock(SkillFileTool.class); + when(runtime.findActiveSkill("nope")).thenReturn(null); + SkillLoadTool tool = new SkillLoadTool(runtime, fileTool); + + String out = tool.loadSkill("nope", null, null); + + assertTrue(out.contains("not found")); + assertTrue(out.contains("listAvailableSkills")); + verify(fileTool, never()).readSkillFile(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("known skill with no filePath loads SKILL.md via the shared reader") + void loadsSkillMdByDefault() { + SkillRuntimeService runtime = mock(SkillRuntimeService.class); + SkillFileTool fileTool = mock(SkillFileTool.class); + when(runtime.findActiveSkill("foo")).thenReturn(skill("foo")); + when(fileTool.readSkillFile(eq("foo"), eq("SKILL.md"), isNull(), isNull(), any())) + .thenReturn("SKILL CONTENT"); + SkillLoadTool tool = new SkillLoadTool(runtime, fileTool); + + String out = tool.loadSkill("foo", null, null); + + assertEquals("SKILL CONTENT", out); + verify(fileTool).readSkillFile(eq("foo"), eq("SKILL.md"), isNull(), isNull(), any()); + } + + @Test + @DisplayName("explicit filePath is forwarded to the shared reader") + void loadsExplicitSubFile() { + SkillRuntimeService runtime = mock(SkillRuntimeService.class); + SkillFileTool fileTool = mock(SkillFileTool.class); + when(runtime.findActiveSkill("foo")).thenReturn(skill("foo")); + when(fileTool.readSkillFile(eq("foo"), eq("references/api.md"), isNull(), isNull(), any())) + .thenReturn("REF CONTENT"); + SkillLoadTool tool = new SkillLoadTool(runtime, fileTool); + + String out = tool.loadSkill("foo", "references/api.md", null); + + assertEquals("REF CONTENT", out); + verify(fileTool).readSkillFile(eq("foo"), eq("references/api.md"), isNull(), isNull(), any()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/disclosure/ToolDisclosureServiceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/disclosure/ToolDisclosureServiceTest.java new file mode 100644 index 00000000..15ba8aad --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/disclosure/ToolDisclosureServiceTest.java @@ -0,0 +1,205 @@ +package vip.mate.tool.disclosure; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.test.util.ReflectionTestUtils; +import vip.mate.agent.AgentToolSet; +import vip.mate.tool.ToolRegistry; +import vip.mate.tool.mcp.model.McpServerEntity; +import vip.mate.tool.mcp.service.McpServerService; +import vip.mate.tool.model.AvailableToolDTO; +import vip.mate.tool.model.ToolEntity; +import vip.mate.tool.service.AvailableToolService; +import vip.mate.tool.service.ToolService; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ToolDisclosureServiceTest { + + /** Fixture beans whose @Tool function names drive tier resolution. */ + static class Tools { + @Tool(description = "text to image") + public String image_generate() { return ""; } + + @Tool(description = "a plain core tool") + public String my_core_tool() { return ""; } + } + + /** Fixture whose class simple name is {@code ImageGenerateTool} and function + * name is {@code image_generate} — mirrors the real builtin's name skew so + * the class-name → function-name bridge can be tested. */ + static class ImageGenerateTool { + @Tool(description = "text to image") + public String image_generate() { return ""; } + } + + /** Global tool set the bridge resolves DB class/bean names against. */ + private static AgentToolSet globalSet() { + Object t1 = new Tools(); + Object t2 = new ImageGenerateTool(); + List cbs = new ArrayList<>(); + cbs.addAll(List.of(ToolCallbacks.from(t1))); + cbs.addAll(List.of(ToolCallbacks.from(t2))); + Map beanNames = Map.of(t1, "tools", t2, "imageGenerateTool"); + return AgentToolSet.fromCallbacks(List.of(t1, t2), cbs, beanNames::get); + } + + private static ToolEntity toolRow(String name, String type, String tier) { + ToolEntity t = new ToolEntity(); + t.setName(name); + t.setToolType(type); + t.setDisclosureTier(tier); + return t; + } + + private static McpServerEntity server(Long id, String name, String tier) { + McpServerEntity s = new McpServerEntity(); + s.setId(id); + s.setName(name); + s.setDisclosureTier(tier); + return s; + } + + private static AvailableToolDTO mcpDto(String name, Long serverId) { + return AvailableToolDTO.builder().source("mcp").providerId(serverId).name(name).build(); + } + + private DefaultToolDisclosureService service(List tools, + List servers, + List available) { + ToolService ts = mock(ToolService.class); + McpServerService ms = mock(McpServerService.class); + AvailableToolService as = mock(AvailableToolService.class); + ToolRegistry tr = mock(ToolRegistry.class); + lenient().when(ts.listTools()).thenReturn(tools); + lenient().when(ms.listAll()).thenReturn(servers); + lenient().when(as.listAvailable()).thenReturn(available); + lenient().when(tr.getEnabledToolSet()).thenReturn(globalSet()); + return new DefaultToolDisclosureService(ts, ms, as, tr); + } + + @Test + @DisplayName("meta-tools enable_tool / load_skill are always core") + void metaToolsAlwaysCore() { + var svc = service(List.of(toolRow("enable_tool", "builtin", "extension")), List.of(), List.of()); + assertEquals(DisclosureTier.CORE, svc.resolveTierByName("enable_tool")); + assertEquals(DisclosureTier.CORE, svc.resolveTierByName("load_skill")); + } + + @Test + @DisplayName("generative tools default to extension even without a DB row") + void generativeDefaultsExtension() { + var svc = service(List.of(), List.of(), List.of()); + assertEquals(DisclosureTier.EXTENSION, svc.resolveTierByName("image_generate")); + assertEquals(DisclosureTier.EXTENSION, svc.resolveTierByName("browser_use")); + } + + @Test + @DisplayName("unknown tools default to core (conservative)") + void unknownDefaultsCore() { + var svc = service(List.of(), List.of(), List.of()); + assertEquals(DisclosureTier.CORE, svc.resolveTierByName("memory_recall")); + } + + @Test + @DisplayName("mate_tool.disclosure_tier overrides the code default") + void dbRowOverrides() { + var svc = service(List.of(toolRow("my_core_tool", "builtin", "extension")), List.of(), List.of()); + assertEquals(DisclosureTier.EXTENSION, svc.resolveTierByName("my_core_tool")); + } + + @Test + @DisplayName("DB tier stored by Java class name bridges to the runtime function name") + void dbTierBridgesClassNameToFunctionName() { + // mate_tool.name = class name; resolveTier is queried by function name. + var hidden = service(List.of(toolRow("ImageGenerateTool", "builtin", "extension")), List.of(), List.of()); + assertEquals(DisclosureTier.EXTENSION, hidden.resolveTierByName("image_generate")); + + // Admin un-hides it by setting the row to core; the DB value must win over + // the code-level extension default. + var unhidden = service(List.of(toolRow("ImageGenerateTool", "builtin", "core")), List.of(), List.of()); + assertEquals(DisclosureTier.CORE, unhidden.resolveTierByName("image_generate")); + } + + @Test + @DisplayName("MCP tool tier follows its owning server") + void mcpFollowsServer() { + var extSvc = service(List.of(), List.of(server(7L, "github", "extension")), + List.of(mcpDto("mcp_github_create_issue", 7L))); + assertEquals(DisclosureTier.EXTENSION, extSvc.resolveTierByName("mcp_github_create_issue")); + + var coreSvc = service(List.of(), List.of(server(7L, "github", "core")), + List.of(mcpDto("mcp_github_create_issue", 7L))); + assertEquals(DisclosureTier.CORE, coreSvc.resolveTierByName("mcp_github_create_issue")); + } + + @Test + @DisplayName("MCP tool whose server has no tier set defaults to core (visible)") + void mcpDefaultsCoreWhenServerTierUnset() { + var svc = service(List.of(), List.of(server(7L, "github", null)), + List.of(mcpDto("mcp_github_create_issue", 7L))); + assertEquals(DisclosureTier.CORE, svc.resolveTierByName("mcp_github_create_issue")); + } + + @Test + @DisplayName("split partitions into active (core + enabled) and the full extension catalog") + void splitPartitions() { + var svc = service(List.of(), List.of(), List.of()); + AgentToolSet set = AgentToolSet.fromCallbacks(List.of(new Tools()), + List.of(ToolCallbacks.from(new Tools()))); + + var noneEnabled = svc.split(set, Set.of()); + assertEquals(List.of("my_core_tool"), names(noneEnabled.activeCallbacks())); + assertEquals(List.of("image_generate"), names(noneEnabled.extensionCatalog())); + + var imgEnabled = svc.split(set, Set.of("image_generate")); + assertTrue(names(imgEnabled.activeCallbacks()).contains("image_generate")); + assertTrue(names(imgEnabled.activeCallbacks()).contains("my_core_tool")); + assertEquals(List.of("image_generate"), names(imgEnabled.extensionCatalog())); + } + + @Test + @DisplayName("legacy mode advertises everything and renders no catalog") + void legacyMode() { + var svc = service(List.of(), List.of(), List.of()); + ReflectionTestUtils.setField(svc, "disclosureMode", "legacy"); + AgentToolSet set = AgentToolSet.fromCallbacks(List.of(new Tools()), + List.of(ToolCallbacks.from(new Tools()))); + + var split = svc.split(set, Set.of()); + assertEquals(2, split.activeCallbacks().size()); + assertTrue(split.extensionCatalog().isEmpty()); + assertEquals("", svc.renderExtensionCatalog(set, 8192)); + assertEquals(DisclosureTier.CORE, svc.resolveTierByName("image_generate")); + } + + @Test + @DisplayName("renderExtensionCatalog lists extension tools under a heading") + void rendersCatalog() { + var svc = service(List.of(), List.of(), List.of()); + AgentToolSet set = AgentToolSet.fromCallbacks(List.of(new Tools()), + List.of(ToolCallbacks.from(new Tools()))); + String catalog = svc.renderExtensionCatalog(set, 8192); + assertTrue(catalog.contains("## Extension Tools")); + assertTrue(catalog.contains("image_generate")); + assertTrue(catalog.contains("enable_tool")); + assertFalse(catalog.contains("my_core_tool"), "core tools must not appear in the extension catalog"); + } + + private static List names(List cbs) { + return cbs.stream().map(c -> c.getToolDefinition().name()).toList(); + } +} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index a023ef00..09227e38 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -431,6 +431,13 @@ export const toolApi = { delete: (id: string | number) => http.delete(`/tools/${id}`), toggle: (id: string | number, enabled: boolean) => http.put(`/tools/${id}/toggle?enabled=${enabled}`), + /** + * Set a builtin/channel tool's progressive-disclosure tier + * ('core' | 'extension'). MCP/ACP/skill tools are tiered at their owning + * source and return 409 here. + */ + setDisclosureTier: (id: string | number, tier: 'core' | 'extension') => + http.put(`/tools/${id}/disclosure-tier`, { tier }), } // ==================== Channel ==================== @@ -486,6 +493,9 @@ export const mcpApi = { http.put(`/mcp/servers/${id}/toggle?enabled=${enabled}`), test: (id: string | number) => http.post(`/mcp/servers/${id}/test`), refresh: () => http.post('/mcp/servers/refresh'), + /** Set the whole server's tool disclosure tier ('core' | 'extension'). */ + setDisclosureTier: (id: string | number, tier: 'core' | 'extension') => + http.put(`/mcp/servers/${id}/disclosure-tier`, { tier }), } // ==================== Plan ==================== diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 4c51a553..25e02ef6 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1649,6 +1649,12 @@ export default { enable: 'Enable', disable: 'Disable', }, + tier: { + core: 'Core', + extension: 'Extension', + coreHint: "Core: this server's tools are advertised to the model directly. Click to make extension.", + extensionHint: "Extension: this server's tools live in the tool box, activated after enable_tool. Click to make core.", + }, kv: { envKey: 'KEY', envValue: 'value', @@ -1731,6 +1737,7 @@ export default { toggleSuccess: 'Status updated', refreshSuccess: 'All connections refreshed', saveFailed: 'Failed to save', + tierFailed: 'Failed to change disclosure tier', empty: 'No MCP connections', emptyDesc: 'Add an MCP connection to extend your agents\' capabilities', }, @@ -1787,6 +1794,21 @@ export default { actions: 'Actions', }, empty: 'No tools registered', + sections: { + core: 'Core Tools', + extension: 'Extension Tools', + countItems: '{n} items', + }, + tier: { + toCore: '→ Core', + toExtension: '→ Extension', + toCoreHint: 'Make core: advertised to the model directly', + toExtensionHint: 'Make extension: lives in the tool box, activated after enable_tool', + locked: 'Source-owned', + lockedHint: "MCP / ACP / Skill tools are tiered by their owning server / endpoint / skill — change it there", + core: { desc: 'Advertised to the model directly' }, + extension: { desc: 'Lives in the tool box; activated after the model calls enable_tool' }, + }, modal: { editTitle: 'Edit Tool', newTitle: 'Register Tool', @@ -1813,6 +1835,7 @@ export default { deleteTitle: 'Confirm Delete', deleteFailed: 'Failed to delete tool', toggleFailed: 'Failed to toggle tool status', + tierFailed: 'Failed to change tool tier', }, }, skillTemplates: { @@ -3130,6 +3153,14 @@ export default { disabled: 'Disabled', scanFailed: 'Scan failed', }, + sections: { + enabled: 'Enabled', + available: 'Disabled', + countItems: '{n} items', + emptyEnabled: 'No enabled skills', + emptyEnabledDesc: 'Enable one from the Disabled section below, or add a new one from a template or import.', + emptyAvailable: 'No disabled skills', + }, sort: { recommended: 'Recommended', name: 'Name', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 7af81073..b481c8ad 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1541,6 +1541,12 @@ export default { enable: '启用', disable: '停用', }, + tier: { + core: '核心', + extension: '扩展', + coreHint: '当前为核心:该 server 的工具直接进入模型可调用列表。点击改为扩展。', + extensionHint: '当前为扩展:该 server 的工具进入工具盒,模型调用 enable_tool 后激活。点击改为核心。', + }, kv: { envKey: 'KEY', envValue: '值', @@ -1623,6 +1629,7 @@ export default { toggleSuccess: '状态已更新', refreshSuccess: '全量刷新完成', saveFailed: '保存失败', + tierFailed: '调整披露分级失败', empty: '暂无 MCP 连接', emptyDesc: '添加一个 MCP 连接来扩展 Agent 的能力', }, @@ -1679,6 +1686,21 @@ export default { actions: '操作', }, empty: '暂无已注册工具', + sections: { + core: '核心工具', + extension: '扩展工具', + countItems: '{n} 项', + }, + tier: { + toCore: '→ 核心', + toExtension: '→ 扩展', + toCoreHint: '改为核心:直接进入模型可调用列表', + toExtensionHint: '改为扩展:进入工具盒目录,调用 enable_tool 后激活', + locked: '由来源决定', + lockedHint: 'MCP / ACP / Skill 工具的分级由所属 server / endpoint / skill 决定,请到对应页面修改', + core: { desc: '直接进入模型可调用列表' }, + extension: { desc: '进入工具盒目录,模型调用 enable_tool 后激活' }, + }, modal: { editTitle: '编辑工具', newTitle: '注册工具', @@ -1705,6 +1727,7 @@ export default { deleteTitle: '确认删除', deleteFailed: '删除工具失败', toggleFailed: '切换工具状态失败', + tierFailed: '调整工具分级失败', }, }, skillTemplates: { @@ -3222,6 +3245,14 @@ export default { disabled: '已禁用', scanFailed: '扫描失败', }, + sections: { + enabled: '已启用', + available: '未启用', + countItems: '{n} 项', + emptyEnabled: '暂无已启用的技能', + emptyEnabledDesc: '从下方"未启用"区启用一个,或从模板/导入新建。', + emptyAvailable: '暂无未启用的技能', + }, sort: { recommended: '推荐排序', name: '按名称', diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 1e7ad2f9..34898c8c 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -508,6 +508,9 @@ export interface Tool { paramsSchema?: string enabled: boolean builtin?: boolean + /** Progressive-disclosure tier: 'core' | 'extension'. Null/absent = core. */ + disclosureTier?: string + channelId?: string | number createTime: string } diff --git a/mateclaw-ui/src/views/McpServers.vue b/mateclaw-ui/src/views/McpServers.vue index e656c69b..1c9a5f19 100644 --- a/mateclaw-ui/src/views/McpServers.vue +++ b/mateclaw-ui/src/views/McpServers.vue @@ -68,6 +68,7 @@ @edit="openEditModal" @test="testServer" @toggle="toggleServer" + @set-tier="setServerTier" />

@@ -140,6 +141,8 @@ import { computed, onMounted, ref } from 'vue' import { useI18n } from 'vue-i18n' import McPagination from '@/components/common/McPagination.vue' import { mcConfirm } from '@/components/common/useConfirm' +import { mcToast } from '@/composables/useMcToast' +import { mcpApi } from '@/api/index' import { useMcpServers } from '@/composables/useMcpServers' import McpCard from './mcp/McpCard.vue' import McpFormModal from './mcp/McpFormModal.vue' @@ -212,6 +215,15 @@ async function onDelete(server: McpServer) { if (ok) modalVisible.value = false } +async function setServerTier(server: McpServer, tier: 'core' | 'extension') { + try { + await mcpApi.setDisclosureTier(server.id, tier) + await reload() + } catch (e: any) { + mcToast.error(e?.message || t('mcp.messages.tierFailed')) + } +} + onMounted(reload) diff --git a/mateclaw-ui/src/views/SkillMarket.vue b/mateclaw-ui/src/views/SkillMarket.vue index ff6b5238..c2025196 100644 --- a/mateclaw-ui/src/views/SkillMarket.vue +++ b/mateclaw-ui/src/views/SkillMarket.vue @@ -49,7 +49,8 @@
- +
-
- -
+
+
+ {{ section.label }} + {{ t('skills.sections.countItems', { n: section.state.total }) }} +
+ +
-
-
🛠️
-

{{ t('skills.empty') }}

-

{{ t('skills.emptyDesc') }}

-
+
+
🛠️
+

{{ section.key === 'enabled' ? t('skills.sections.emptyEnabled') : t('skills.sections.emptyAvailable') }}

+

{{ t('skills.sections.emptyEnabledDesc') }}

+
- -
- + +
+ +
@@ -717,8 +720,23 @@ import { useSkillName } from '@/composables/useSkillName' const { t } = useI18n() const { resolveSkillName, hasI18nName } = useSkillName() -const skills = ref([]) -const total = ref(0) + +/** Two independently-paginated sections: enabled vs disabled. The per-card + * status pill still marks blocked / scan-failed skills within each section. */ +interface SkillSegment { + items: Skill[] + total: number + page: number + size: number +} +const segments = reactive<{ enabled: SkillSegment; available: SkillSegment }>({ + enabled: { items: [], total: 0, page: 1, size: 20 }, + available: { items: [], total: 0, page: 1, size: 20 }, +}) +const skillSections = computed(() => [ + { key: 'enabled' as const, label: t('skills.sections.enabled'), state: segments.enabled }, + { key: 'available' as const, label: t('skills.sections.available'), state: segments.available }, +]) const counts = ref>({}) const runtimeStatusMap = ref>({}) const showModal = ref(false) @@ -727,11 +745,8 @@ const refreshing = ref(false) const showImportDialog = ref(false) const query = reactive({ - page: 1, - size: 20, keyword: '', skillType: 'all' as string, - statusFilter: '' as string, sort: 'recommended' as string, /** '' = active+stale catalog; 'stale' / 'archived' = lifecycle tabs. */ lifecycleState: '' as string, @@ -841,7 +856,7 @@ function openPreflight(skill: Skill) { async function onSkillInstalled(payload?: { name?: string }) { await loadAll() if (!payload?.name) return - const installed = skills.value.find(s => s.name === payload.name) + const installed = findLoadedSkill(s => s.name === payload.name) if (!installed) return if (needsSetup(installed)) { openPreflight(installed) @@ -1000,7 +1015,7 @@ let searchDebounce: ReturnType | null = null watch(() => query.keyword, () => { if (searchDebounce) clearTimeout(searchDebounce) searchDebounce = setTimeout(() => { - query.page = 1 + resetSegmentPages() loadSkills() }, 300) }) @@ -1013,32 +1028,39 @@ function onTabChange(tab: { value: string; kind: string }) { query.lifecycleState = '' query.skillType = tab.value } - query.page = 1 + resetSegmentPages() loadSkills() } function onFilterChange() { - query.page = 1 + resetSegmentPages() loadSkills() } -/** McPagination emits a single change event with both page and size, - * so we don't need separate handlers — just reload the list. */ -function onPagerChange() { - loadSkills() +/** Reset both sections to page 1 — used whenever a shared filter changes. */ +function resetSegmentPages() { + segments.enabled.page = 1 + segments.available.page = 1 } -async function loadSkills(allowPageClamp = true) { +/** Reload both sections. Kept named loadSkills so existing call sites + * (create / toggle / delete / refresh) reload the whole view unchanged. */ +async function loadSkills() { + await Promise.all([loadSegment('enabled'), loadSegment('available')]) +} + +async function loadSegment(key: 'enabled' | 'available', allowPageClamp = true) { + const seg = segments[key] try { - const params: Record = { page: query.page, size: query.size } + const params: Record = { + page: seg.page, + size: seg.size, + // The enabled flag IS the section split: 已启用 vs 未启用. + enabled: key === 'enabled', + } if (query.keyword) params.keyword = query.keyword.trim() if (query.skillType && query.skillType !== 'all') params.skillType = query.skillType if (query.sort) params.sort = query.sort - // Map the single status filter onto the backend's two independent params: - // enabled (bool) and scanStatus (PASSED/FAILED). scan_failed implies any enabled state. - if (query.statusFilter === 'enabled') params.enabled = true - else if (query.statusFilter === 'disabled') params.enabled = false - else if (query.statusFilter === 'scan_failed') params.scanStatus = 'FAILED' if (query.lifecycleState) params.lifecycleState = query.lifecycleState const res: any = await skillApi.page(params) @@ -1051,31 +1073,29 @@ async function loadSkills(allowPageClamp = true) { // successor; issue #48). const reportedTotal = Number(data.total) || 0 if (reportedTotal > 0) { - const pageCount = Math.max(1, Math.ceil(reportedTotal / query.size)) - if (allowPageClamp && query.page > pageCount) { - query.page = pageCount - await loadSkills(false) + const pageCount = Math.max(1, Math.ceil(reportedTotal / seg.size)) + if (allowPageClamp && seg.page > pageCount) { + seg.page = pageCount + await loadSegment(key, false) return } - total.value = reportedTotal + seg.total = reportedTotal } else if (records.length > 0) { - total.value = records.length >= query.size - ? query.page * query.size + 1 - : (query.page - 1) * query.size + records.length - // eslint-disable-next-line no-console - console.warn('[SkillMarket] backend returned records but total=0; rebuild server JAR to pick up the DbType fix') + seg.total = records.length >= seg.size + ? seg.page * seg.size + 1 + : (seg.page - 1) * seg.size + records.length } else { - if (allowPageClamp && query.page > 1) { - query.page = 1 - await loadSkills(false) + if (allowPageClamp && seg.page > 1) { + seg.page = 1 + await loadSegment(key, false) return } - total.value = 0 + seg.total = 0 } - skills.value = records + seg.items = records } catch (e) { - skills.value = [] - total.value = 0 + seg.items = [] + seg.total = 0 } } @@ -1132,7 +1152,7 @@ async function createSkillFromModal() { await loadAll() const created: Skill | undefined = res?.data if (created) { - const fresh = skills.value.find(s => s.id === created.id) || created + const fresh = findLoadedSkill(s => s.id === created.id) || created openDetailDrawer(fresh, 'overview', { editIdentity: true }) } } catch (e: any) { @@ -1273,9 +1293,22 @@ function closeDetailDrawer() { detailDrawerVisible.value = false } +/** Find a loaded skill across both sections (read-only lookups). */ +function findLoadedSkill(pred: (s: Skill) => boolean): Skill | undefined { + return segments.enabled.items.find(pred) || segments.available.items.find(pred) +} + +/** Patch a row in-place in whichever section holds it, so a panel update + * doesn't need a full reload. (Enable-toggle, which moves a skill between + * sections, reloads both sections instead.) */ function patchSkillInPlace(updated: Skill) { - const idx = skills.value.findIndex(s => s.id === updated.id) - if (idx >= 0) skills.value.splice(idx, 1, { ...skills.value[idx], ...updated }) + for (const seg of [segments.enabled, segments.available]) { + const idx = seg.items.findIndex(s => s.id === updated.id) + if (idx >= 0) { + seg.items.splice(idx, 1, { ...seg.items[idx], ...updated }) + return + } + } } async function deleteSkill(idOrSkill: string | number | Skill) { @@ -1285,7 +1318,7 @@ async function deleteSkill(idOrSkill: string | number | Skill) { // Resolve to the skill record so we can call the uninstall path by name. const skill: Skill | undefined = typeof idOrSkill === 'object' ? idOrSkill - : skills.value.find(s => s.id === idOrSkill) + : findLoadedSkill(s => s.id === idOrSkill) if (!skill) return const ok = await mcConfirm({ title: t('skills.messages.deleteTitle'), @@ -1432,8 +1465,7 @@ async function rescanSkill(skill: Skill) { const updated: Skill | undefined = res?.data if (updated) { // Patch the row in-place so the panel updates without a full page reload. - const idx = skills.value.findIndex(s => s.id === skill.id) - if (idx >= 0) skills.value.splice(idx, 1, { ...skills.value[idx], ...updated }) + patchSkillInPlace(updated) mcToast.success( updated.securityScanStatus === 'FAILED' ? t('skills.security.rescanStillFailed') @@ -1880,6 +1912,11 @@ html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); } } /* 技能网格 */ +.skill-section { margin-top: 22px; } +.skill-section:first-of-type { margin-top: 8px; } +.skill-section-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; } +.skill-section-title { font-size: 15px; font-weight: 700; color: var(--mc-text-primary); } +.skill-section-count { font-size: 12px; font-weight: 600; color: var(--mc-text-secondary); background: var(--mc-bg-muted); padding: 2px 8px; border-radius: 10px; } .skill-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; } .skill-card { padding: 18px; diff --git a/mateclaw-ui/src/views/Tools.vue b/mateclaw-ui/src/views/Tools.vue index b0ff0c5a..759d652b 100644 --- a/mateclaw-ui/src/views/Tools.vue +++ b/mateclaw-ui/src/views/Tools.vue @@ -16,69 +16,85 @@
- -
- - - - - - - - - - - - - - - - - - - - -
{{ t('tools.columns.tool') }}{{ t('tools.columns.type') }}{{ t('tools.columns.status') }}{{ t('tools.columns.actions') }}
-
-
- - - -
-
-
{{ tool.name }}
-
{{ tool.description }}
- {{ tool.beanName }} -
-
-
- {{ tool.toolType }} - - - -
- - -
-
-
- 🔧 -

{{ t('tools.empty') }}

-
-
+ +
+
+ {{ section.label }} + {{ t('tools.sections.countItems', { n: section.rows.length }) }} + {{ section.hint }} +
+
+ + + + + + + + + + + + + + + + + + + + +
{{ t('tools.columns.tool') }}{{ t('tools.columns.type') }}{{ t('tools.columns.status') }}{{ t('tools.columns.actions') }}
+
+
+ + + +
+
+
{{ tool.name }}
+
{{ tool.description }}
+ {{ tool.beanName }} +
+
+
+ {{ tool.toolType }} + + + +
+ + {{ t('tools.tier.locked') }} + + +
+
+
+ 🔧 +

{{ t('tools.empty') }}

+
+
+
@@ -128,7 +144,7 @@