mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(tool,skill,ui): progressive tool/skill disclosure (load_skill + enable_tool + tier UI)
This commit is contained in:
parent
773c64bfd7
commit
cef1730e6e
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 协议模型(AnthropicChatModel):MiniMax 也用此协议但不支持 thinking
|
||||
if (chatModel instanceof org.springframework.ai.anthropic.AnthropicChatModel anthropicModel) {
|
||||
org.springframework.ai.anthropic.AnthropicChatOptions.Builder builder =
|
||||
org.springframework.ai.anthropic.AnthropicChatOptions.builder()
|
||||
.toolCallbacks(toolCallbacks)
|
||||
.toolCallbacks(activeCallbacks)
|
||||
.internalToolExecutionEnabled(false);
|
||||
|
||||
// 仅对真正的 Claude 模型启用 extended thinking(MiniMax 等走 Anthropic 协议但不支持)
|
||||
@ -792,7 +871,7 @@ public class ReasoningNode implements NodeAction {
|
||||
effectiveMaxTokens = DASHSCOPE_MAX_OUTPUT_TOKENS;
|
||||
}
|
||||
OpenAiChatOptions.Builder oaiBuilder = OpenAiChatOptions.builder()
|
||||
.toolCallbacks(toolCallbacks)
|
||||
.toolCallbacks(activeCallbacks)
|
||||
.maxTokens(effectiveMaxTokens);
|
||||
if (StringUtils.hasText(effectiveReasoning)) {
|
||||
oaiBuilder.reasoningEffort(effectiveReasoning);
|
||||
|
||||
@ -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())));
|
||||
|
||||
@ -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 已有值上 */
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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.";
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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());
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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');
|
||||
@ -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');
|
||||
@ -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');
|
||||
@ -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');
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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 ====================
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '按名称',
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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. */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user