feat(tool,skill,ui): progressive tool/skill disclosure (load_skill + enable_tool + tier UI)

This commit is contained in:
matevip 2026-05-23 09:07:45 +08:00
parent 773c64bfd7
commit cef1730e6e
41 changed files with 2180 additions and 163 deletions

View File

@ -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<String> 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<vip.mate.llm.failover.FallbackEntry> 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<vip.mate.llm.failover.FallbackEntry> 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<String> 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<Long> 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<String> boundTools,
Integer maxInputTokens) {
Set<Long> boundSkillIds = agentBindingService.getBoundSkillIds(entity.getId());
Long agentId = entity.getId();
Long workspaceId = entity.getWorkspaceId();
return loaded -> skillRuntimeService.buildSkillPromptEnhancement(
boundSkillIds, boundTools, maxInputTokens, agentId, workspaceId, loaded);
}
/**

View File

@ -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<String> functionNamesFor(Set<String> aliases) {
if (aliases == null || aliases.isEmpty()) {
return Set.of();
}
return resolveAliases(aliases).stream()
.map(cb -> cb.getToolDefinition().name())
.collect(Collectors.toCollection(LinkedHashSet::new));
}
// ==================== Internals ====================
/**

View File

@ -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",

View File

@ -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<String> requestedSkills = extractLoadedSkillNames(toolCalls);
if (!requestedSkills.isEmpty()) {
Set<String> 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<String> enabledTools = extractEnabledToolNames(toolCalls);
if (!enabledTools.isEmpty()) {
Set<String> 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<String> extractEnabledToolNames(List<AssistantMessage.ToolCall> toolCalls) {
if (toolCalls == null || toolCalls.isEmpty()) {
return Set.of();
}
Set<String> 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<String> extractLoadedSkillNames(List<AssistantMessage.ToolCall> toolCalls) {
if (toolCalls == null || toolCalls.isEmpty()) {
return Set.of();
}
Set<String> 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;
}
}
}

View File

@ -102,6 +102,18 @@ public class ReasoningNode implements NodeAction {
private final ChatModel chatModel;
private final List<ToolCallback> 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<ToolCallback> 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<Message> 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<ToolCallback> 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<ToolCallback> activeCallbacks) {
// Anthropic 协议模型AnthropicChatModelMiniMax 也用此协议但不支持 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 thinkingMiniMax 等走 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);

View File

@ -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())));

View File

@ -228,6 +228,26 @@ public final class MateClawStateAccessor {
return state.<ChatOrigin>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<String> loadedSkills() {
return state.<Set<String>>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<String> enabledExtensionTools() {
return state.<Set<String>>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<String> names) {
return put(LOADED_SKILLS, names);
}
// ---- Tool progressive disclosure ----
public OutputBuilder enabledExtensionTools(Set<String> names) {
return put(ENABLED_EXTENSION_TOOLS, names);
}
// ---- Token Usage ----
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */

View File

@ -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<String>} 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).
* <p>
* 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<String>}; 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).
* <p>
* MUST be registered in both KeyStrategyFactory blocks (see
* {@link #LOADED_SKILLS}).
*/
public static final String ENABLED_EXTENSION_TOOLS = "enabled_extension_tools";
}

View File

@ -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.
* <p>
* 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<String> loadedThisRun);
}

View File

@ -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<Long> boundSkillIds,
Set<String> effectiveToolNames,
Integer maxInputTokens,
Long agentId,
Long agentWorkspaceId,
Set<String> loadedThisRunNames) {
List<ResolvedSkill> 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<ResolvedSkill> 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<String> loadedNames = loadedThisRunNames == null ? Set.of() : loadedThisRunNames;
List<ResolvedSkill> sorted = applyCatalogSignals(
SkillCatalogSorter.sortResolved(visibleSkills, SkillCatalogSort.RECOMMENDED),
loadedNames, recentNames, frequentNames, recencyCutoff);
List<ResolvedSkill> 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=<name>, filePath=\"SKILL.md\")` and follow its instructions. ");
if (loadSkillToolEnabled) {
sb.append("first call `load_skill(skillName=<name>)` 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=<name>, 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=<part of name>` 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=\"<exact-name>\", filePath=\"SKILL.md\")` directly — ");
if (loadSkillToolEnabled) {
sb.append("call `load_skill(skillName=\"<exact-name>\")` directly — ");
} else {
sb.append("call `readSkillFile(skillName=\"<exact-name>\", 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=<name>, 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.
* <p>
* Package-private and static so it can be unit-tested without standing up
* the full service.
*/
static List<ResolvedSkill> applyCatalogSignals(List<ResolvedSkill> recommended,
Set<String> loadedThisRunNames,
Set<String> recentNames,
Set<String> frequentNames,
java.time.LocalDateTime recencyCutoff) {
Set<String> loaded = loadedThisRunNames == null ? Set.of() : loadedThisRunNames;
Set<String> recent = recentNames == null ? Set.of() : recentNames;
Set<String> 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<String> effectiveToolNames) {
if (effectiveToolNames == null) return true;
Set<String> tools = skill.getEffectiveAllowedTools();

View File

@ -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.
* <p>
* 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<String> 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.";
}
}

View File

@ -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.
* <p>
* 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.
* <p>
* 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);
}
}

View File

@ -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<ToolEntity> 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<ToolEntity> setDisclosureTier(@PathVariable Long id, @RequestBody Map<String, String> 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);
}
}

View File

@ -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<String> 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<String> 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<String> enabledExtensions) {
List<ToolCallback> all = baseSet == null ? List.of() : baseSet.callbacks();
if (legacyMode()) {
return new ToolDisclosureSplit(all, List.of());
}
Set<String> enabled = enabledExtensions == null ? Set.of() : enabledExtensions;
List<ToolCallback> active = new ArrayList<>(all.size());
List<ToolCallback> 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<ToolCallback> 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=\"<name>\")`, 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<String, DisclosureTier> 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<String> 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<String, Long> 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<Long, DisclosureTier> serverTierById = new LinkedHashMap<>();
Map<Long, String> 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<String, DisclosureTier> builtinTierByName,
Map<String, Long> mcpToolToServerId,
Map<Long, DisclosureTier> serverTierById,
Map<Long, String> serverNameById,
long builtAtMillis) {
}
}

View File

@ -0,0 +1,36 @@
package vip.mate.tool.disclosure;
/**
* Progressive tool disclosure tier.
* <ul>
* <li>{@link #CORE} always advertised to the LLM.</li>
* <li>{@link #EXTENSION} hidden behind the extension-tools catalog until
* the model calls {@code enable_tool}, which activates it for the rest of
* the conversation.</li>
* </ul>
*/
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()));
}
}

View File

@ -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.
*
* <p>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<String> 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<ToolCallback> activeCallbacks, List<ToolCallback> extensionCatalog) {
}
}

View File

@ -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<McpServerEntity> setDisclosureTier(@PathVariable Long id,
@RequestBody java.util.Map<String, String> 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")

View File

@ -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;

View File

@ -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());

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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

View File

@ -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');

View File

@ -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');

View File

@ -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');

View File

@ -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');

View File

@ -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<AssistantMessage.ToolCall> 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<AssistantMessage.ToolCall> 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<AssistantMessage.ToolCall> 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<AssistantMessage.ToolCall> 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<AssistantMessage.ToolCall> 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());
}
}

View File

@ -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.
*
* <p>{@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.
*
* <p>{@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");
}
}
}

View File

@ -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<SkillEntity> 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;
}
}

View File

@ -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"));
}
}

View File

@ -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());
}
}

View File

@ -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<org.springframework.ai.tool.ToolCallback> cbs = new ArrayList<>();
cbs.addAll(List.of(ToolCallbacks.from(t1)));
cbs.addAll(List.of(ToolCallbacks.from(t2)));
Map<Object, String> 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<ToolEntity> tools,
List<McpServerEntity> servers,
List<AvailableToolDTO> 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<String> names(List<ToolCallback> cbs) {
return cbs.stream().map(c -> c.getToolDefinition().name()).toList();
}
}

View File

@ -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 ====================

View File

@ -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',

View File

@ -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: '按名称',

View File

@ -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
}

View File

@ -68,6 +68,7 @@
@edit="openEditModal"
@test="testServer"
@toggle="toggleServer"
@set-tier="setServerTier"
/>
</div>
<div v-if="installedTotal > pageSize" class="mcp-pager-row">
@ -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)
</script>

View File

@ -49,7 +49,8 @@
</button>
</div>
<!-- Search + status filter (RFC-042 §2.1) -->
<!-- Search + sort. Enabled vs disabled is now the section split below,
so the old status dropdown is gone. -->
<div class="skill-filter-bar mc-surface-card">
<input
v-model="query.keyword"
@ -57,12 +58,6 @@
type="search"
:placeholder="t('skills.search.placeholder')"
/>
<select v-model="query.statusFilter" class="skill-status-filter" @change="onFilterChange">
<option value="">{{ t('skills.filter.all') }}</option>
<option value="enabled">{{ t('skills.filter.enabled') }}</option>
<option value="disabled">{{ t('skills.filter.disabled') }}</option>
<option value="scan_failed">{{ t('skills.filter.scanFailed') }}</option>
</select>
<select v-model="query.sort" class="skill-status-filter" @change="onFilterChange">
<option value="recommended">{{ t('skills.sort.recommended') }}</option>
<option value="name">{{ t('skills.sort.name') }}</option>
@ -72,12 +67,19 @@
</select>
</div>
<!-- Skill grid RFC-090 §4.2 (Phase 1 slim).
<!-- Enabled / Available two-section layout. Each section paginates
independently via the enabled=true|false query split.
Card surfaces 5 things: icon · name · status · description · actions.
All findings, deps, paths, lessons, used-by are in the detail drawer. -->
<div class="skill-grid" v-if="skills.length > 0">
<div v-for="section in skillSections" :key="section.key" class="skill-section">
<div class="skill-section-head">
<span class="skill-section-title">{{ section.label }}</span>
<span class="skill-section-count">{{ t('skills.sections.countItems', { n: section.state.total }) }}</span>
</div>
<div class="skill-grid" v-if="section.state.items.length > 0">
<div
v-for="skill in skills"
v-for="skill in section.state.items"
:key="skill.id"
class="skill-card mc-surface-card"
:class="{ disabled: !skill.enabled }"
@ -181,21 +183,22 @@
</div>
</div>
<div v-else class="empty-state mc-surface-card">
<div class="empty-icon">🛠</div>
<h3>{{ t('skills.empty') }}</h3>
<p>{{ t('skills.emptyDesc') }}</p>
</div>
<div v-else class="empty-state mc-surface-card">
<div class="empty-icon">🛠</div>
<h3>{{ section.key === 'enabled' ? t('skills.sections.emptyEnabled') : t('skills.sections.emptyAvailable') }}</h3>
<p v-if="section.key === 'enabled'">{{ t('skills.sections.emptyEnabledDesc') }}</p>
</div>
<!-- Pagination (RFC-042 §2.1) MateClaw frosted-pill component. -->
<div class="skill-pagination">
<McPagination
v-model:page="query.page"
v-model:size="query.size"
:total="total"
:sizes="[20, 50]"
@change="onPagerChange"
/>
<!-- Per-section pagination MateClaw frosted-pill component. -->
<div v-if="section.state.total > section.state.size" class="skill-pagination">
<McPagination
v-model:page="section.state.page"
v-model:size="section.state.size"
:total="section.state.total"
:sizes="[20, 50]"
@change="() => loadSegment(section.key)"
/>
</div>
</div>
</div>
</div>
@ -717,8 +720,23 @@ import { useSkillName } from '@/composables/useSkillName'
const { t } = useI18n()
const { resolveSkillName, hasI18nName } = useSkillName()
const skills = ref<Skill[]>([])
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<Record<string, number>>({})
const runtimeStatusMap = ref<Record<string, SkillRuntimeStatus>>({})
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<typeof setTimeout> | 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<string, unknown> = { page: query.page, size: query.size }
const params: Record<string, unknown> = {
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;

View File

@ -16,69 +16,85 @@
</button>
</div>
<!-- 工具列表 -->
<div class="tools-table-wrap mc-surface-card">
<table class="tools-table">
<thead>
<tr>
<th>{{ t('tools.columns.tool') }}</th>
<th>{{ t('tools.columns.type') }}</th>
<th>{{ t('tools.columns.status') }}</th>
<th>{{ t('tools.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="tool in tools" :key="tool.id" class="tool-row">
<td>
<div class="tool-info">
<div class="tool-icon-wrap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
</div>
<div>
<div class="tool-name">{{ tool.name }}</div>
<div class="tool-desc">{{ tool.description }}</div>
<code class="tool-bean">{{ tool.beanName }}</code>
</div>
</div>
</td>
<td>
<span class="type-badge" :class="'type-' + tool.toolType">{{ tool.toolType }}</span>
</td>
<td>
<label class="toggle-switch">
<input type="checkbox" :checked="tool.enabled" @change="toggleTool(tool)" />
<span class="toggle-slider"></span>
</label>
</td>
<td>
<div class="row-actions">
<button class="row-btn" @click="openEditModal(tool)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="row-btn danger" @click="deleteTool(tool.id)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
</div>
</td>
</tr>
<tr v-if="tools.length === 0">
<td colspan="4" class="empty-row">
<div class="empty-state">
<span class="empty-icon">🔧</span>
<p>{{ t('tools.empty') }}</p>
</div>
</td>
</tr>
</tbody>
</table>
<!-- 工具列表核心 / 扩展 双段式 -->
<div v-for="section in sections" :key="section.key" class="tools-section">
<div class="tools-section-head">
<span class="tools-section-title">{{ section.label }}</span>
<span class="tools-section-count">{{ t('tools.sections.countItems', { n: section.rows.length }) }}</span>
<span class="tools-section-hint">{{ section.hint }}</span>
</div>
<div class="tools-table-wrap mc-surface-card">
<table class="tools-table">
<thead>
<tr>
<th>{{ t('tools.columns.tool') }}</th>
<th>{{ t('tools.columns.type') }}</th>
<th>{{ t('tools.columns.status') }}</th>
<th>{{ t('tools.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="tool in section.rows" :key="tool.id" class="tool-row">
<td>
<div class="tool-info">
<div class="tool-icon-wrap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
</div>
<div>
<div class="tool-name">{{ tool.name }}</div>
<div class="tool-desc">{{ tool.description }}</div>
<code class="tool-bean">{{ tool.beanName }}</code>
</div>
</div>
</td>
<td>
<span class="type-badge" :class="'type-' + tool.toolType">{{ tool.toolType }}</span>
</td>
<td>
<label class="toggle-switch">
<input type="checkbox" :checked="tool.enabled" @change="toggleTool(tool)" />
<span class="toggle-slider"></span>
</label>
</td>
<td>
<div class="row-actions">
<button
v-if="canEditTier(tool)"
class="row-btn tier-btn"
:title="section.key === 'core' ? t('tools.tier.toExtensionHint') : t('tools.tier.toCoreHint')"
@click="moveTier(tool, section.key === 'core' ? 'extension' : 'core')"
>
{{ section.key === 'core' ? t('tools.tier.toExtension') : t('tools.tier.toCore') }}
</button>
<span v-else class="tier-locked" :title="t('tools.tier.lockedHint')">{{ t('tools.tier.locked') }}</span>
<button class="row-btn" @click="openEditModal(tool)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="row-btn danger" @click="deleteTool(tool.id)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
</div>
</td>
</tr>
<tr v-if="section.rows.length === 0">
<td colspan="4" class="empty-row">
<div class="empty-state">
<span class="empty-icon">🔧</span>
<p>{{ t('tools.empty') }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
@ -128,7 +144,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import { mcConfirm } from '@/components/common/useConfirm'
@ -137,6 +153,42 @@ import type { Tool } from '@/types/index'
const { t } = useI18n()
const tools = ref<Tool[]>([])
type Tier = 'core' | 'extension'
function effectiveTier(tool: Tool): Tier {
return tool.disclosureTier === 'extension' ? 'extension' : 'core'
}
// Only builtin / channel atomic tools are tiered on the row itself; MCP / ACP /
// skill tools are tiered at their owning source, so their control is locked.
function canEditTier(tool: Tool): boolean {
return tool.toolType === 'builtin' || tool.toolType === 'channel'
}
const sections = computed(() => [
{
key: 'core' as Tier,
label: t('tools.sections.core'),
hint: t('tools.tier.core.desc'),
rows: tools.value.filter((tool) => effectiveTier(tool) === 'core'),
},
{
key: 'extension' as Tier,
label: t('tools.sections.extension'),
hint: t('tools.tier.extension.desc'),
rows: tools.value.filter((tool) => effectiveTier(tool) === 'extension'),
},
])
async function moveTier(tool: Tool, tier: Tier) {
try {
await toolApi.setDisclosureTier(tool.id, tier)
await loadTools()
} catch (e: any) {
mcToast.error(e?.message || t('tools.messages.tierFailed'))
}
}
const showModal = ref(false)
const editingTool = ref<Tool | null>(null)
@ -209,6 +261,14 @@ async function toggleTool(tool: Tool) {
<style scoped>
.tools-page { gap: 18px; }
.tools-section { display: flex; flex-direction: column; gap: 10px; margin-bottom: 22px; }
.tools-section-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.tools-section-title { font-size: 15px; font-weight: 700; color: var(--mc-text-primary); }
.tools-section-count { font-size: 12px; font-weight: 600; color: var(--mc-text-secondary); background: var(--mc-bg-muted); padding: 2px 8px; border-radius: 10px; }
.tools-section-hint { font-size: 12px; color: var(--mc-text-tertiary); }
.tier-btn { width: auto; padding: 0 10px; font-size: 12px; font-weight: 600; white-space: nowrap; color: var(--mc-text-secondary); }
.tier-btn:hover { color: var(--mc-primary); border-color: var(--mc-primary); }
.tier-locked { display: inline-flex; align-items: center; padding: 0 8px; font-size: 11px; color: var(--mc-text-tertiary); cursor: help; }
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover)); color: white; border: none; border-radius: 14px; font-size: 14px; font-weight: 600; cursor: pointer; box-shadow: var(--mc-shadow-soft); }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }

View File

@ -31,6 +31,14 @@
<div v-if="!isCatalog" class="mcp-card-meta">
{{ t('mcp.card.toolCount', { n: server!.toolCount || 0 }) }}
<template v-if="lastSeen"> · {{ lastSeen }}</template>
<button
class="mcp-tier-pill"
:class="{ 'mcp-tier-pill--ext': tier === 'extension' }"
:title="tier === 'extension' ? t('mcp.tier.extensionHint') : t('mcp.tier.coreHint')"
@click.stop="emit('setTier', server!, tier === 'extension' ? 'core' : 'extension')"
>
{{ tier === 'extension' ? t('mcp.tier.extension') : t('mcp.tier.core') }}
</button>
</div>
</div>
@ -130,6 +138,7 @@ const emit = defineEmits<{
(e: 'edit', server: McpServer): void
(e: 'test', server: McpServer): void
(e: 'toggle', server: McpServer): void
(e: 'setTier', server: McpServer, tier: 'core' | 'extension'): void
(e: 'add', entry: McpCatalogEntry): void
(e: 'docs', url: string): void
}>()
@ -137,6 +146,11 @@ const emit = defineEmits<{
const { t } = useI18n()
const isCatalog = computed(() => !!props.catalogEntry)
// Whole-server disclosure tier; defaults to core (matches the DB default) so
// MCP tools stay directly callable until an admin moves the server to extension.
const tier = computed<'core' | 'extension'>(() =>
props.server?.disclosureTier === 'extension' ? 'extension' : 'core',
)
const displayName = computed(() =>
isCatalog.value ? props.catalogEntry!.name : props.server!.name,
)
@ -317,6 +331,23 @@ function onPrimaryAction() {
color: var(--mc-text-tertiary);
}
.mcp-tier-pill {
margin-left: 6px;
padding: 1px 7px;
border: 1px solid var(--mc-border);
background: var(--mc-bg-sunken);
color: var(--mc-text-tertiary);
border-radius: 999px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
cursor: pointer;
transition: all 0.15s;
}
.mcp-tier-pill:hover { border-color: var(--mc-primary); color: var(--mc-primary); }
.mcp-tier-pill--ext { background: var(--mc-primary-bg); color: var(--mc-primary); border-color: transparent; }
/* Always-visible enable toggle on installed cards. Sits to the right
of the card body; hover-reveal actions slide in to its left. */
.mcp-card-toggle {

View File

@ -22,6 +22,8 @@ export interface McpServer {
lastConnectedTime: string
toolCount: number
builtin: boolean
/** Whole-server disclosure tier: 'core' | 'extension'. Null/absent = core. */
disclosureTier?: string
}
/** Result of POST /api/v1/mcp/servers/{id}/test. */