feat(skill): boost newly installed skills + teach LLM to widen the catalog search

This commit is contained in:
matevip 2026-05-12 17:20:09 +08:00
parent 0b321dc903
commit 5789a28e19
4 changed files with 86 additions and 6 deletions

View File

@ -335,6 +335,7 @@ public class SkillPackageResolver {
.enabled(Boolean.TRUE.equals(entity.getEnabled()))
.icon(entity.getIcon())
.builtin(Boolean.TRUE.equals(entity.getBuiltin()))
.createTime(entity.getCreateTime())
.build();
}
@ -375,6 +376,7 @@ public class SkillPackageResolver {
.enabled(Boolean.TRUE.equals(entity.getEnabled()))
.icon(entity.getIcon())
.builtin(Boolean.TRUE.equals(entity.getBuiltin()))
.createTime(entity.getCreateTime())
.build();
}

View File

@ -395,10 +395,17 @@ public class SkillRuntimeService {
int descLimit = promptDescriptionLimit(maxInputTokens);
Set<String> recentNames = usageService.recentLoadedSkillNames(agentId, 8);
Set<String> frequentNames = usageService.frequentlyLoadedSkillNames(8);
// Boost freshly installed skills for a short window so a skill the
// user *just* added is visible in the compact catalog before it has
// any usage history. Without this, qwen-turbo-style 8-entry budgets
// 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) -> recentNames.contains(s.getName()) ? 0 : 1)
.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();
@ -416,7 +423,12 @@ public class SkillRuntimeService {
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. ");
sb.append("If none of these skills match, call `listAvailableSkills()` to inspect the broader catalog. ");
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 — ");
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("| Skill | Status | Description |\n");
@ -458,6 +470,28 @@ public class SkillRuntimeService {
return tools == null || tools.isEmpty() || effectiveToolNames.containsAll(tools);
}
/**
* Treat skills installed within this window as "new" for the prompt
* catalog ranker. Long enough that a user who installs on Friday and
* comes back Monday still sees the boost; short enough that the
* catalog reverts to usage-based ordering before the boost slot
* crowds out genuinely useful skills.
*/
public static final java.time.Duration NEW_SKILL_BOOST_WINDOW = java.time.Duration.ofDays(7);
/**
* Returns true if the skill's row was created after {@code cutoff}.
* Builtins and virtual MCP/ACP skills typically have no createTime;
* they are not boosted (the user didn't just install them). Public so
* the user-facing {@code listAvailableSkills} catalog can apply the
* same boost as the prompt enhancement.
*/
public static boolean isRecentlyInstalled(ResolvedSkill skill, java.time.LocalDateTime cutoff) {
if (skill == null || skill.getCreateTime() == null) return false;
if (skill.isBuiltin()) return false;
return skill.getCreateTime().isAfter(cutoff);
}
private static int promptCatalogEntryLimit(Integer maxInputTokens) {
int max = maxInputTokens != null && maxInputTokens > 0 ? maxInputTokens : 8192;
if (max <= 8192) return 8;

View File

@ -6,6 +6,7 @@ import lombok.Data;
import vip.mate.skill.manifest.SkillManifest;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@ -72,6 +73,16 @@ public class ResolvedSkill {
@Builder.Default
private boolean builtin = false;
/**
* Skill row create timestamp, copied from {@code mate_skill.create_time}.
* Used by the prompt-catalog ranker to surface freshly installed skills
* before they accumulate any usage stats without this, a brand-new
* skill stays invisible behind the recent/frequent/alphabetical sort
* and the LLM ends up replying "no such skill" right after the user
* installed it. Null for virtual MCP/ACP skills that don't own a row.
*/
private LocalDateTime createTime;
// ==================== 安全扫描状态 ====================
/** 是否被安全扫描阻断 */

View File

@ -265,7 +265,7 @@ public class SkillFileTool {
}
@Tool(description = """
List all currently available Skills (documentation packages).
List currently available Skills (documentation packages).
IMPORTANT: Skills are NOT directly callable as tools. Each name
returned here is a `skillName` argument, not a tool name. To use
@ -273,6 +273,17 @@ public class SkillFileTool {
first to read its instructions, then follow what SKILL.md tells you.
Calling a skill name as a tool will fail with "Tool not found".
Search strategy when looking for a specific skill:
- The default page is 20 of N if "Showing: 20 of <larger>" appears
and you don't see what you're after, retry with `keyword=<name fragment>`
(matched against name + description, case-insensitive) or raise `limit`
up to 50.
- If the user mentions an exact skill name (e.g. "tencent-meeting-mcp"),
skip this tool and go straight to
`readSkillFile(skillName="<exact-name>", filePath="SKILL.md")`
that bypasses the catalog truncation entirely and either returns
the skill's instructions or a clear "skill not found" error.
Note: this returns Skills (vendor-installable docs), not Agents.
For Agents, use `listAvailableAgents`.
@ -280,7 +291,7 @@ public class SkillFileTool {
""")
public String listAvailableSkills(
@JsonProperty(required = false)
@JsonPropertyDescription("Optional keyword matched against skill name or description")
@JsonPropertyDescription("Optional keyword matched against skill name or description (case-insensitive). Use this when a specific skill name was mentioned but didn't appear in the default page.")
String keyword,
@JsonProperty(required = false)
@ -299,6 +310,16 @@ public class SkillFileTool {
int safeLimit = limit == null || limit <= 0 ? 20 : Math.min(limit, 50);
String kw = keyword == null ? "" : keyword.trim().toLowerCase();
// Push freshly installed skills to the top of the truncated page so
// a user who just installed something can still find it without
// remembering to pass keyword=. Same window the prompt catalog uses.
java.time.LocalDateTime recencyCutoff = java.time.LocalDateTime.now()
.minus(SkillRuntimeService.NEW_SKILL_BOOST_WINDOW);
// sortResolved gives the RECOMMENDED ordering; the secondary sort
// below uses the JDK's stable sort to lift recently-installed skills
// to the top while preserving RECOMMENDED order among same-recency
// entries no need to thread the (package-private) recommended
// comparator back through here.
List<ResolvedSkill> activeSkills = SkillCatalogSorter.sortResolved(
runtimeService.getActiveSkills().stream()
.filter(s -> SkillCatalogSorter.sourceMatches(s, source))
@ -307,7 +328,10 @@ public class SkillFileTool {
|| containsIgnoreCase(s.getName(), kw)
|| containsIgnoreCase(s.getDescription(), kw))
.toList(),
SkillCatalogSort.RECOMMENDED);
SkillCatalogSort.RECOMMENDED).stream()
.sorted(java.util.Comparator.comparingInt((ResolvedSkill s) ->
SkillRuntimeService.isRecentlyInstalled(s, recencyCutoff) ? 0 : 1))
.toList();
if (activeSkills.isEmpty()) {
return "No skills are currently available.";
@ -338,8 +362,17 @@ public class SkillFileTool {
}
sb.append(" |\n");
}
sb.append("\nShowing: ").append(Math.min(safeLimit, activeSkills.size()))
int shown = Math.min(safeLimit, activeSkills.size());
sb.append("\nShowing: ").append(shown)
.append(" of ").append(activeSkills.size()).append(" skill(s).");
if (shown < activeSkills.size()) {
// Surface the truncation hint so the LLM knows how to widen the
// search instead of concluding the missing skill doesn't exist.
sb.append(" Result truncated — retry with `keyword=<part of name>` ")
.append("to search the full catalog, or `limit=50` to see more rows. ")
.append("If the user gave an exact skill name, prefer ")
.append("`readSkillFile(skillName=\"<name>\", filePath=\"SKILL.md\")` directly.");
}
return sb.toString();
}