mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
685 lines
32 KiB
Java
685 lines
32 KiB
Java
package vip.mate.skill.runtime;
|
||
|
||
import com.github.benmanes.caffeine.cache.Cache;
|
||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.context.annotation.Lazy;
|
||
import org.springframework.stereotype.Service;
|
||
import vip.mate.skill.acp.AcpSkillBridge;
|
||
import vip.mate.skill.lessons.SkillLessonsService;
|
||
import vip.mate.skill.manifest.SkillManifest;
|
||
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 vip.mate.skill.workspace.SkillWorkspaceEvent;
|
||
|
||
import jakarta.annotation.PostConstruct;
|
||
import jakarta.annotation.PreDestroy;
|
||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||
import org.springframework.context.event.EventListener;
|
||
import java.time.Duration;
|
||
import java.util.LinkedHashSet;
|
||
import java.util.List;
|
||
import java.util.Set;
|
||
import java.util.concurrent.Executors;
|
||
import java.util.concurrent.ScheduledExecutorService;
|
||
import java.util.concurrent.ScheduledFuture;
|
||
import java.util.concurrent.TimeUnit;
|
||
import java.util.concurrent.atomic.AtomicReference;
|
||
import java.util.stream.Collectors;
|
||
|
||
/**
|
||
* 技能运行时服务
|
||
* 管理 active skills 运行时视图,提供缓存和刷新机制
|
||
*/
|
||
@Slf4j
|
||
@Service
|
||
public class SkillRuntimeService {
|
||
|
||
private final SkillService skillService;
|
||
private final SkillPackageResolver packageResolver;
|
||
/**
|
||
* {@code @Lazy} — SkillLessonsService depends on SkillWorkspaceManager,
|
||
* which is constructed early; the lazy proxy avoids a chicken-and-egg
|
||
* cycle when the runtime service initializes alongside the skill
|
||
* service stack. Using setter-style injection through the
|
||
* constructor below.
|
||
*/
|
||
private final SkillLessonsService lessonsService;
|
||
/**
|
||
* RFC-090 §3.2 / §10.2 Q2 — MCP-server → virtual-skill bridge.
|
||
* {@code @Lazy} because the bridge depends on McpClientManager
|
||
* which boots later in the lifecycle.
|
||
*/
|
||
private final McpSkillBridge mcpSkillBridge;
|
||
/**
|
||
* RFC-090 §3.2 (parallel) — ACP-endpoint → virtual-skill bridge.
|
||
* Same {@code @Lazy} treatment as the MCP bridge.
|
||
*/
|
||
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,
|
||
@Lazy SkillLessonsService lessonsService,
|
||
@Lazy McpSkillBridge mcpSkillBridge,
|
||
@Lazy AcpSkillBridge acpSkillBridge,
|
||
@Lazy SkillUsageService usageService) {
|
||
this.skillService = skillService;
|
||
this.packageResolver = packageResolver;
|
||
this.lessonsService = lessonsService;
|
||
this.mcpSkillBridge = mcpSkillBridge;
|
||
this.acpSkillBridge = acpSkillBridge;
|
||
this.usageService = usageService;
|
||
}
|
||
|
||
// 缓存已解析的 active skills(5分钟过期)
|
||
private final Cache<String, List<ResolvedSkill>> activeSkillsCache = Caffeine.newBuilder()
|
||
.expireAfterWrite(Duration.ofMinutes(5))
|
||
.maximumSize(10)
|
||
.build();
|
||
|
||
private static final String CACHE_KEY = "active_skills";
|
||
|
||
/**
|
||
* Debounce window for {@link #onWorkspaceEvent}. Startup typically
|
||
* fires one event per bundled skill (30+ in a row), and there's
|
||
* nothing useful to do until the whole batch settles. 500 ms is
|
||
* long enough to swallow the burst without making admin re-syncs
|
||
* feel laggy.
|
||
*/
|
||
private static final long REFRESH_DEBOUNCE_MS = 500;
|
||
|
||
private final ScheduledExecutorService refreshScheduler =
|
||
Executors.newSingleThreadScheduledExecutor(r -> {
|
||
Thread t = new Thread(r, "skill-refresh-debouncer");
|
||
t.setDaemon(true);
|
||
return t;
|
||
});
|
||
|
||
/** Most recent pending refresh future — atomically swapped on each
|
||
* event so the previous one can be cancelled. */
|
||
private final AtomicReference<ScheduledFuture<?>> pendingRefresh = new AtomicReference<>();
|
||
|
||
@PostConstruct
|
||
public void init() {
|
||
log.info("SkillRuntimeService initialized");
|
||
// 设置反向引用,避免循环依赖
|
||
skillService.setRuntimeService(this);
|
||
}
|
||
|
||
@EventListener(ApplicationReadyEvent.class)
|
||
public void onApplicationReady() {
|
||
// 延迟到 ApplicationReady 事件触发,确保 SQL 初始化脚本已执行完毕
|
||
refreshActiveSkills();
|
||
}
|
||
|
||
@EventListener(SkillWorkspaceEvent.class)
|
||
public void onWorkspaceEvent(SkillWorkspaceEvent event) {
|
||
// Coalesce bursts of workspace events (every bundled-skill sync at
|
||
// startup fires one) into a single refresh. Without debounce, a
|
||
// 30-skill startup triggered 30 sequential refreshActiveSkills()
|
||
// calls — each running the whole resolve+scan loop. With 500 ms
|
||
// debounce it collapses to one.
|
||
log.debug("Workspace event: {} {} (refresh scheduled in {}ms)",
|
||
event.type(), event.skillName(), REFRESH_DEBOUNCE_MS);
|
||
ScheduledFuture<?> previous = pendingRefresh.get();
|
||
if (previous != null && !previous.isDone()) {
|
||
previous.cancel(false);
|
||
}
|
||
ScheduledFuture<?> task = refreshScheduler.schedule(() -> {
|
||
try {
|
||
refreshActiveSkills();
|
||
} catch (Exception e) {
|
||
log.warn("Debounced refresh failed: {}", e.getMessage());
|
||
}
|
||
}, REFRESH_DEBOUNCE_MS, TimeUnit.MILLISECONDS);
|
||
pendingRefresh.set(task);
|
||
}
|
||
|
||
@PreDestroy
|
||
public void shutdown() {
|
||
// Drain any pending refresh so JVM shutdown doesn't hang on the
|
||
// daemon thread, even though it's marked daemon and would die anyway.
|
||
ScheduledFuture<?> pending = pendingRefresh.getAndSet(null);
|
||
if (pending != null) pending.cancel(true);
|
||
refreshScheduler.shutdown();
|
||
try {
|
||
if (!refreshScheduler.awaitTermination(2, TimeUnit.SECONDS)) {
|
||
refreshScheduler.shutdownNow();
|
||
}
|
||
} catch (InterruptedException e) {
|
||
refreshScheduler.shutdownNow();
|
||
Thread.currentThread().interrupt();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* RFC-090 §14.1 — single source of truth for "is this resolved
|
||
* skill currently exposable to an agent". Both the global
|
||
* {@link #refreshActiveSkills()} cache and the per-agent
|
||
* {@link #buildSkillPromptEnhancement(Set)} branch route through
|
||
* here so the two views can never disagree.
|
||
*
|
||
* <p>Rules:
|
||
* <ol>
|
||
* <li>row enabled, runtime resolved, not security-blocked — required</li>
|
||
* <li>manifest present → at least one feature READY ({@code hasAnyActiveFeature})</li>
|
||
* <li>manifest absent (legacy SKILL.md) → fall back to
|
||
* {@code dependencyReady} so old skills behave unchanged</li>
|
||
* </ol>
|
||
*/
|
||
public static boolean passesActiveGate(ResolvedSkill s) {
|
||
if (s == null) return false;
|
||
if (!s.isEnabled() || !s.isRuntimeAvailable() || s.isSecurityBlocked()) return false;
|
||
if (s.getManifest() == null) return s.isDependencyReady();
|
||
return s.hasAnyActiveFeature();
|
||
}
|
||
|
||
/**
|
||
* 获取当前启用的技能列表(运行时视图)
|
||
*/
|
||
public List<ResolvedSkill> getActiveSkills() {
|
||
List<ResolvedSkill> cached = activeSkillsCache.getIfPresent(CACHE_KEY);
|
||
if (cached != null) {
|
||
return cached;
|
||
}
|
||
return refreshActiveSkills();
|
||
}
|
||
|
||
/**
|
||
* 刷新 active skills 缓存
|
||
* 进入 active set 的 skill 必须同时满足:
|
||
* 1. enabled == true
|
||
* 2. runtimeAvailable == true
|
||
* 3. securityBlocked == false
|
||
* 4. dependencyReady == true
|
||
*/
|
||
public List<ResolvedSkill> refreshActiveSkills() {
|
||
List<SkillEntity> enabledSkills = skillService.listEnabledSkills();
|
||
|
||
List<ResolvedSkill> resolved = enabledSkills.stream()
|
||
.map(packageResolver::resolve)
|
||
.filter(SkillRuntimeService::passesActiveGate)
|
||
.collect(Collectors.toList());
|
||
// Track real skill names so a same-named bridged virtual skill is
|
||
// suppressed (real wins). Without this, a real SKILL.md packaged
|
||
// alongside a same-name MCP/ACP server produces two cards on the
|
||
// Skills page.
|
||
Set<String> realNames = resolved.stream()
|
||
.map(ResolvedSkill::getName)
|
||
.collect(Collectors.toSet());
|
||
try {
|
||
// MCP-derived virtual skills go through the same active gate
|
||
// so a disconnected MCP server doesn't pollute the prompt
|
||
// enhancement. Same-name virtuals are suppressed by the real
|
||
// skill above.
|
||
for (ResolvedSkill virt : mcpSkillBridge.listMcpDerivedResolvedSkills()) {
|
||
if (realNames.contains(virt.getName())) continue;
|
||
if (passesActiveGate(virt)) resolved.add(virt);
|
||
}
|
||
} catch (Exception e) {
|
||
log.warn("MCP skill bridge active merge failed: {}", e.getMessage());
|
||
}
|
||
try {
|
||
// ACP-derived virtual skills. Same dedup as MCP.
|
||
for (ResolvedSkill virt : acpSkillBridge.listAcpDerivedResolvedSkills()) {
|
||
if (realNames.contains(virt.getName())) continue;
|
||
if (passesActiveGate(virt)) resolved.add(virt);
|
||
}
|
||
} catch (Exception e) {
|
||
log.warn("ACP skill bridge active merge failed: {}", e.getMessage());
|
||
}
|
||
|
||
activeSkillsCache.put(CACHE_KEY, resolved);
|
||
log.info("Refreshed active skills: {} enabled", resolved.size());
|
||
|
||
return resolved;
|
||
}
|
||
|
||
/**
|
||
* 解析所有技能的运行时状态(管理页面使用,包含 disabled 和 error 信息)
|
||
*
|
||
* <p>RFC-090 §3.2 — appends virtual MCP-derived skills so the Skills
|
||
* page can render MCP servers as first-class skill cards. Real
|
||
* skills resolve through the full pipeline; virtual ones come
|
||
* pre-built from {@link McpSkillBridge}.
|
||
*/
|
||
public List<ResolvedSkill> resolveAllSkillsStatus() {
|
||
List<SkillEntity> allSkills = skillService.listSkills();
|
||
List<ResolvedSkill> resolved = allSkills.stream()
|
||
.map(packageResolver::resolve)
|
||
.collect(Collectors.toList());
|
||
// Same dedup-by-name as refreshActiveSkills(): a real skill with
|
||
// the same name as a bridged virtual one suppresses the virtual,
|
||
// so the Skills admin page never shows two cards for the same name.
|
||
Set<String> realNames = resolved.stream()
|
||
.map(ResolvedSkill::getName)
|
||
.collect(Collectors.toSet());
|
||
try {
|
||
for (ResolvedSkill virt : mcpSkillBridge.listMcpDerivedResolvedSkills()) {
|
||
if (realNames.contains(virt.getName())) continue;
|
||
resolved.add(virt);
|
||
}
|
||
} catch (Exception e) {
|
||
log.warn("MCP skill bridge merge failed: {}", e.getMessage());
|
||
}
|
||
try {
|
||
for (ResolvedSkill virt : acpSkillBridge.listAcpDerivedResolvedSkills()) {
|
||
if (realNames.contains(virt.getName())) continue;
|
||
resolved.add(virt);
|
||
}
|
||
} catch (Exception e) {
|
||
log.warn("ACP skill bridge merge failed: {}", e.getMessage());
|
||
}
|
||
return resolved;
|
||
}
|
||
|
||
/**
|
||
* Rescan one skill on demand (RFC-042 §2.3.4) — runs the full resolver
|
||
* pipeline (content + security + dependency), which writes the updated
|
||
* scan result to DB as a side-effect, and then invalidates the active
|
||
* skills cache so subsequent reads reflect the new status.
|
||
*/
|
||
public ResolvedSkill rescanSingle(SkillEntity skill) {
|
||
ResolvedSkill resolved = packageResolver.resolve(skill);
|
||
activeSkillsCache.invalidateAll();
|
||
log.info("Rescanned skill '{}' (id={}): status={}, blocked={}",
|
||
skill.getName(), skill.getId(),
|
||
skill.getSecurityScanStatus(), resolved.isSecurityBlocked());
|
||
return resolved;
|
||
}
|
||
|
||
/**
|
||
* RFC-090 review #3 — explicit lifecycle hook so SkillService
|
||
* can deregister wrapper tools without poking at the resolver
|
||
* directly. Safe to call for skill ids that never had wrappers.
|
||
*/
|
||
public void deregisterSkillWrappers(Long skillId) {
|
||
try {
|
||
packageResolver.deregisterSkillWrappers(skillId);
|
||
} catch (Exception e) {
|
||
log.warn("Failed to deregister wrappers for skill {}: {}", skillId, e.getMessage());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 根据名称查找 active skill
|
||
*/
|
||
public ResolvedSkill findActiveSkill(String name) {
|
||
return getActiveSkills().stream()
|
||
.filter(s -> s.getName().equals(name))
|
||
.findFirst()
|
||
.orElse(null);
|
||
}
|
||
|
||
/**
|
||
* 构建技能 prompt 增强片段(全局,向后兼容)
|
||
*/
|
||
public String buildSkillPromptEnhancement() {
|
||
return buildSkillPromptEnhancement(null);
|
||
}
|
||
|
||
/**
|
||
* 构建技能 prompt 增强片段(支持 per-agent 过滤)
|
||
*
|
||
* @param boundSkillIds Agent 绑定的 skill ID 集合。null 表示使用全局默认(无绑定)。
|
||
* 非 null 时仅包含指定 ID 的 skill。
|
||
*/
|
||
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds) {
|
||
return buildSkillPromptEnhancement(boundSkillIds, null, null, null);
|
||
}
|
||
|
||
/**
|
||
* 构建技能目录提示片段。
|
||
*
|
||
* @param boundSkillIds Agent 绑定的 skill ID 集合。null 表示使用全局默认(无绑定)。
|
||
* @param effectiveToolNames 当前 agent 可见的工具名集合。null 表示不按 agent 绑定限制过滤。
|
||
* @param maxInputTokens 当前模型最大输入窗口,用于控制目录大小。
|
||
*/
|
||
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds,
|
||
Set<String> effectiveToolNames,
|
||
Integer maxInputTokens) {
|
||
return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, null, null);
|
||
}
|
||
|
||
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds,
|
||
Set<String> effectiveToolNames,
|
||
Integer maxInputTokens,
|
||
Long agentId) {
|
||
return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, agentId, null);
|
||
}
|
||
|
||
/**
|
||
* 构建技能目录提示片段(支持按 Agent 工作区隔离)。
|
||
*
|
||
* @param agentWorkspaceId 调用 Agent 的工作区 ID。非 null 时,目录只保留
|
||
* 内置技能(全局)与该工作区拥有的技能;其他工作区
|
||
* 的技能不会注入 prompt。null 表示不做工作区过滤
|
||
* (调试预览等全局场景)。
|
||
*/
|
||
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds,
|
||
Set<String> effectiveToolNames,
|
||
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
|
||
// already-merged active set (real + MCP/ACP virtual). Using
|
||
// getActiveSkills() — instead of a fresh
|
||
// skillService.listEnabledSkills() walk — is what makes bound
|
||
// virtual skills surface in the prompt catalog. The earlier
|
||
// implementation only looked at mate_skill rows, so a user
|
||
// who explicitly checked an MCP/ACP card in the agent picker
|
||
// got its tools (via AgentBindingService.getEffectiveToolNames)
|
||
// but lost the corresponding `## Skills` catalog row, which
|
||
// confused the LLM when it tried to dispatch by skill name.
|
||
// Cache-backed get + same passesActiveGate semantics, so this
|
||
// is strictly additive for real skills.
|
||
activeSkills = getActiveSkills().stream()
|
||
.filter(s -> s.getId() != null && boundSkillIds.contains(s.getId()))
|
||
.collect(java.util.stream.Collectors.toList());
|
||
} else {
|
||
activeSkills = getActiveSkills();
|
||
}
|
||
// Platform filter — drop skills whose `platforms:` frontmatter
|
||
// names a different OS than the runtime host. apple-notes /
|
||
// findmy etc. are macOS-only; surfacing them on Linux just
|
||
// burns prompt tokens for skills the user can never run.
|
||
// Empty / missing `platforms:` means "all platforms" (the default).
|
||
String currentOs = currentOsCanonical();
|
||
activeSkills = activeSkills.stream()
|
||
.filter(s -> matchesCurrentPlatform(s, currentOs))
|
||
.collect(java.util.stream.Collectors.toList());
|
||
// Workspace filter — a workspace-B agent must not see workspace-A's
|
||
// skills in its catalog. Builtin skills are global; virtual MCP
|
||
// skills carry no workspace (null) and stay globally visible. Only
|
||
// applied when the caller supplies the agent's workspace; the debug
|
||
// preview passes null to keep its global view.
|
||
if (agentWorkspaceId != null) {
|
||
activeSkills = activeSkills.stream()
|
||
.filter(s -> matchesWorkspace(s, agentWorkspaceId))
|
||
.collect(java.util.stream.Collectors.toList());
|
||
}
|
||
if (activeSkills.isEmpty()) {
|
||
return "";
|
||
}
|
||
|
||
List<ResolvedSkill> visibleSkills = activeSkills.stream()
|
||
.filter(s -> isVisibleWithTools(s, effectiveToolNames))
|
||
.collect(java.util.stream.Collectors.toList());
|
||
if (visibleSkills.isEmpty()) return "";
|
||
|
||
Set<Long> boundIds = boundSkillIds == null ? Set.of() : boundSkillIds;
|
||
int maxEntries = promptCatalogEntryLimit(maxInputTokens);
|
||
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);
|
||
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();
|
||
LinkedHashSet<ResolvedSkill> selected = new LinkedHashSet<>();
|
||
selected.addAll(pinned);
|
||
for (ResolvedSkill skill : sorted) {
|
||
if (selected.size() >= Math.max(maxEntries, pinned.size())) break;
|
||
selected.add(skill);
|
||
}
|
||
|
||
StringBuilder sb = new StringBuilder();
|
||
sb.append("\n\n## Skills\n");
|
||
sb.append("This is a compact catalog. If a listed skill matches the task, ");
|
||
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, ");
|
||
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("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) {
|
||
sb.append("| `").append(skill.getName()).append("`");
|
||
if (skill.getIcon() != null && !skill.getIcon().isBlank()) {
|
||
sb.append(" ").append(skill.getIcon());
|
||
}
|
||
sb.append(" | ").append(statusToken(skill)).append(" | ");
|
||
if (skill.getDescription() != null && !skill.getDescription().isBlank()) {
|
||
String desc = skill.getDescription();
|
||
if (desc.length() > descLimit) {
|
||
desc = desc.substring(0, descLimit) + "...";
|
||
}
|
||
// Escape pipe and newline so a multi-line description doesn't
|
||
// break the table layout.
|
||
sb.append(desc.replace("|", "\\|").replace("\n", " "));
|
||
}
|
||
sb.append(" |\n");
|
||
}
|
||
if (selected.size() < visibleSkills.size()) {
|
||
sb.append("\nShowing ").append(selected.size()).append(" of ")
|
||
.append(visibleSkills.size())
|
||
.append(" available skills. Use `listAvailableSkills()` for the full catalog.\n");
|
||
}
|
||
|
||
List<ResolvedSkill> lessonSkills = sorted.stream()
|
||
.filter(s -> (s.getId() != null && boundIds.contains(s.getId())) || recentNames.contains(s.getName()))
|
||
.toList();
|
||
appendLessonsBlock(sb, lessonSkills);
|
||
|
||
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();
|
||
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;
|
||
if (max <= 16384) return 12;
|
||
if (max <= 32768) return 20;
|
||
return 32;
|
||
}
|
||
|
||
private static int promptDescriptionLimit(Integer maxInputTokens) {
|
||
int max = maxInputTokens != null && maxInputTokens > 0 ? maxInputTokens : 8192;
|
||
if (max <= 8192) return 80;
|
||
if (max <= 16384) return 100;
|
||
if (max <= 32768) return 140;
|
||
return 160;
|
||
}
|
||
|
||
private static String statusToken(ResolvedSkill skill) {
|
||
if (skill.isSecurityBlocked()) return "blocked";
|
||
if (!skill.isEnabled()) return "disabled";
|
||
if (!passesActiveGate(skill)) return "setup-needed";
|
||
return "ready";
|
||
}
|
||
|
||
/**
|
||
* Append a "## Lessons learned" block to the prompt enhancement
|
||
* with one subsection per active skill that has lessons recorded.
|
||
*
|
||
* <p>Skills opt in via {@code self-evolution.lessons_enabled} (default
|
||
* true). Skills with no LESSONS.md content contribute nothing — we
|
||
* never emit an empty subsection.
|
||
*/
|
||
private void appendLessonsBlock(StringBuilder sb, List<ResolvedSkill> activeSkills) {
|
||
if (lessonsService == null || activeSkills == null || activeSkills.isEmpty()) return;
|
||
StringBuilder lessons = new StringBuilder();
|
||
for (ResolvedSkill skill : activeSkills) {
|
||
SkillManifest manifest = skill.getManifest();
|
||
boolean enabled = manifest == null
|
||
|| manifest.getSelfEvolution() == null
|
||
|| manifest.getSelfEvolution().isLessonsEnabled();
|
||
if (!enabled) continue;
|
||
String body = lessonsService.readLessonsBody(skill);
|
||
if (body == null || body.isBlank()) continue;
|
||
lessons.append("\n### ").append(skill.getName()).append("\n");
|
||
lessons.append(body).append("\n");
|
||
}
|
||
if (lessons.length() > 0) {
|
||
sb.append("\n\n## Lessons learned\n");
|
||
sb.append("Past observations the agent recorded for these skills. ");
|
||
sb.append("Treat them as advisory hints — the canonical SKILL.md still wins on conflict.\n");
|
||
sb.append(lessons);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Map {@code System.getProperty("os.name")} to one of the canonical
|
||
* tokens used in SKILL.md {@code platforms:} ({@code macos / linux /
|
||
* windows}). Anything unrecognised → {@code "other"} which never
|
||
* matches a declared platform list, so the skill stays visible only
|
||
* if its platforms list is empty (the "all platforms" default).
|
||
*/
|
||
static String currentOsCanonical() {
|
||
String os = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT);
|
||
if (os.contains("mac") || os.contains("darwin")) return "macos";
|
||
if (os.contains("nux") || os.contains("nix")) return "linux";
|
||
if (os.contains("win")) return "windows";
|
||
return "other";
|
||
}
|
||
|
||
/**
|
||
* True when the skill is compatible with {@code currentOs}. A skill
|
||
* with empty / null {@code platforms:} matches every OS (legacy
|
||
* default). Otherwise the canonical OS token must appear in the list.
|
||
*/
|
||
static boolean matchesCurrentPlatform(ResolvedSkill skill, String currentOs) {
|
||
SkillManifest manifest = skill.getManifest();
|
||
if (manifest == null) return true;
|
||
List<String> platforms = manifest.getPlatforms();
|
||
if (platforms == null || platforms.isEmpty()) return true;
|
||
for (String p : platforms) {
|
||
if (p == null) continue;
|
||
if (currentOs.equalsIgnoreCase(p.trim())) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* True when the skill is visible to an agent in {@code agentWorkspaceId}.
|
||
* Builtin skills are global, virtual MCP-derived skills carry no
|
||
* workspace ({@code null}) and are likewise global; every other skill is
|
||
* visible only inside its owning workspace.
|
||
*/
|
||
static boolean matchesWorkspace(ResolvedSkill skill, long agentWorkspaceId) {
|
||
if (skill.isBuiltin()) return true;
|
||
Long skillWs = skill.getWorkspaceId();
|
||
if (skillWs == null) return true;
|
||
return skillWs == agentWorkspaceId;
|
||
}
|
||
}
|