diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index be613b9b..0af112c7 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -151,17 +151,28 @@ public class AgentGraphBuilder { Set boundTools = agentBindingService.getEffectiveToolNames(entity.getId()); toolSet = toolSet.withAllowedToolsOnly(boundTools); // null = 全局默认 - // 统一使用全局默认模型(AgentEntity.modelName 为历史残留字段,不参与运行时选择) - ModelConfigEntity runtimeModel; + // RFC-090 §9.2 调整 C — pick a primary model that satisfies + // the agent's bound-skill requires-model. Falls back to the + // global default when no preferred provider satisfies, so the + // existing "no default model" error path stays intact. + ModelConfigEntity globalDefault; try { - runtimeModel = modelConfigService.getDefaultModel(); + globalDefault = modelConfigService.getDefaultModel(); } catch (Exception e) { throw new MateClawException("err.agent.no_default_model", "无法构建 Agent:请先在「设置 → 模型」中配置并启用默认模型"); } - - // RFC-090 §9.2 调整 C — log a warning when the chosen primary model - // doesn't satisfy the union of bound skills' requires-model. This is - // diagnostics only; the chain order isn't rewritten yet. + ModelConfigEntity runtimeModel; + try { + runtimeModel = providerRouter.selectPrimary(entity.getId(), globalDefault); + if (runtimeModel == null) runtimeModel = globalDefault; + } catch (Exception e) { + log.debug("[ProviderRouter] primary selection failed, falling back to global default: {}", + e.getMessage()); + runtimeModel = globalDefault; + } + // Even after the upgrade, log a WARN when the chosen primary + // still doesn't satisfy needs (e.g. no preferred provider was + // capable). The diagnostic is observability-only. try { providerRouter.diagnosePrimary(entity.getId(), runtimeModel); } catch (Exception e) { @@ -730,6 +741,16 @@ public class AgentGraphBuilder { log.debug("[LlmFailover] agent={} preferences={} -> chain head reordered", agentId, preferred); } + // RFC-090 §9.2 调整 C — second-pass reorder: lift providers + // that satisfy the bound-skill capability set (vision / video / + // audio) ahead of those that don't. Stable otherwise so the + // user-preferred order still wins among capable providers. + try { + providers = new ArrayList<>(providerRouter.reorderForCapabilities(agentId, providers)); + } catch (Exception e) { + log.debug("[ProviderRouter] chain reorder failed: {}", e.getMessage()); + } + List chain = new ArrayList<>(); for (ModelProviderEntity p : providers) { // Don't put the primary provider's row into the fallback chain — same-instance diff --git a/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java b/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java index 383f4f3e..db03ea30 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java @@ -2,7 +2,6 @@ package vip.mate.llm.routing; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import vip.mate.agent.binding.service.AgentBindingService; import vip.mate.llm.model.ModelConfigEntity; @@ -11,8 +10,10 @@ import vip.mate.llm.service.ModelCapabilityService.Modality; import vip.mate.llm.service.ModelConfigService; import vip.mate.skill.manifest.SkillManifest; import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.llm.model.ModelProviderEntity; import vip.mate.skill.runtime.model.ResolvedSkill; +import java.util.ArrayList; import java.util.EnumSet; import java.util.LinkedHashSet; import java.util.List; @@ -133,7 +134,115 @@ public class ProviderRouter { } } - /** {@code @Lazy} hint — kept for symmetry if AgentBindingService is constructed first. */ - @Lazy - public void noopForLazyHint() { /* no-op */ } + // ==================== chain reorder (RFC-090 §9.2 调整 C) ==================== + + /** + * Re-rank an already preference-ordered provider list so providers + * that satisfy the agent's bound-skill {@code requires-model} union + * float to the head. Stable order otherwise — providers that don't + * satisfy keep their existing relative order. + * + *

Called by {@link vip.mate.agent.AgentGraphBuilder#buildFallbackChain} + * after the user-preferences reorder. Only acts when bound skills + * actually declared {@code requires-model}; otherwise returns the + * input untouched. + */ + public List reorderForCapabilities(Long agentId, + List ordered) { + if (ordered == null || ordered.isEmpty()) return ordered; + Set needs = aggregateModelNeeds(agentId); + if (needs.isEmpty()) return ordered; + Set requiredModalities = needs.stream() + .map(this::mapToModality) + .filter(java.util.Objects::nonNull) + .collect(java.util.stream.Collectors.toCollection( + () -> EnumSet.noneOf(Modality.class))); + if (requiredModalities.isEmpty()) { + // No modality-mapped need (e.g. only function_calling + // declared) — let the existing order win. + return ordered; + } + + List satisfying = new ArrayList<>(); + List rest = new ArrayList<>(); + for (ModelProviderEntity p : ordered) { + if (providerSatisfies(p, requiredModalities)) satisfying.add(p); + else rest.add(p); + } + if (satisfying.isEmpty() || satisfying.size() == ordered.size()) { + // Either nothing matches (let preference order ride) or + // everything matches (no work to do). + return ordered; + } + log.info("[ProviderRouter] agent={} reorder: {} provider(s) lifted for needs={}", + agentId, satisfying.size(), requiredModalities); + List reordered = new ArrayList<>(ordered.size()); + reordered.addAll(satisfying); + reordered.addAll(rest); + return reordered; + } + + /** + * Pick a primary {@link ModelConfigEntity} that satisfies as many + * required modalities as possible. Falls back to the global default + * when nothing better is configured. + * + *

Logic: try each preferred provider in turn; for each, ask + * {@link ModelProviderService#getDefaultModelByProvider} for its + * default chat model and check capability resolution. First match + * wins. If nothing matches, return the global default unchanged. + */ + public ModelConfigEntity selectPrimary(Long agentId, ModelConfigEntity globalDefault) { + if (agentId == null) return globalDefault; + Set needs = aggregateModelNeeds(agentId); + if (needs.isEmpty()) return globalDefault; + Set requiredModalities = needs.stream() + .map(this::mapToModality) + .filter(java.util.Objects::nonNull) + .collect(java.util.stream.Collectors.toCollection( + () -> EnumSet.noneOf(Modality.class))); + if (requiredModalities.isEmpty()) return globalDefault; + + // Already satisfies? Skip the search. + if (globalDefault != null) { + EnumSet resolved = capabilityService.resolve( + globalDefault.getModelName(), globalDefault.getModalities()); + if (resolved.containsAll(requiredModalities)) return globalDefault; + } + + List preferred = bindingService.getPreferredProviderIds(agentId); + for (String providerId : preferred) { + ModelConfigEntity candidate = pickProviderDefault(providerId); + if (candidate == null) continue; + EnumSet resolved = capabilityService.resolve( + candidate.getModelName(), candidate.getModalities()); + if (resolved.containsAll(requiredModalities)) { + log.info("[ProviderRouter] agent={} switched primary to {}/{} for needs={}", + agentId, candidate.getProvider(), candidate.getModelName(), + requiredModalities); + return candidate; + } + } + // No preferred provider satisfied; keep the diagnostic warning + // path on the original default so the user sees the gap in logs. + return globalDefault; + } + + private ModelConfigEntity pickProviderDefault(String providerId) { + if (providerId == null || providerId.isBlank()) return null; + try { + return modelConfigService.getDefaultModelByProvider(providerId); + } catch (Exception e) { + // getDefaultModelByProvider can return null or throw when + // the provider has no enabled chat model; treat both as + // "no candidate from this provider". + return null; + } + } + + private boolean providerSatisfies(ModelProviderEntity provider, Set needs) { + ModelConfigEntity def = pickProviderDefault(provider.getProviderId()); + if (def == null) return false; + return capabilityService.resolve(def.getModelName(), def.getModalities()).containsAll(needs); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/knowledge/SkillScopedToolCallback.java b/mateclaw-server/src/main/java/vip/mate/skill/knowledge/SkillScopedToolCallback.java new file mode 100644 index 00000000..4d0a0abc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/knowledge/SkillScopedToolCallback.java @@ -0,0 +1,66 @@ +package vip.mate.skill.knowledge; + +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.metadata.ToolMetadata; +import org.springframework.lang.Nullable; + +import java.util.function.Function; + +/** + * RFC-090 §14.4 — generic {@link ToolCallback} adapter for skill-scoped + * wrapper tools. + * + *

The factory ({@link WikiSkillWrapperToolFactory}) constructs one + * instance per (skill, op) pair, with the bound {@code kbId} captured + * inside the {@link Function} body. The LLM sees only the wrapper name + * (e.g. {@code kb_tcm_classics_search}); the {@code kbId} is never + * passed via {@link ToolContext} or a ThreadLocal — both of which + * §14.4 explicitly bans. + * + *

Why a single class instead of an anonymous lambda: the + * {@link ToolDefinition} surface is verbose enough that anonymous + * inner classes would duplicate the same getter wiring everywhere. + */ +public class SkillScopedToolCallback implements ToolCallback { + + private final ToolDefinition definition; + private final Function handler; + + public SkillScopedToolCallback(String name, + String description, + String inputSchema, + Function handler) { + this.definition = ToolDefinition.builder() + .name(name) + .description(description) + .inputSchema(inputSchema) + .build(); + this.handler = handler; + } + + @Override + public ToolDefinition getToolDefinition() { + return definition; + } + + @Override + public ToolMetadata getToolMetadata() { + return ToolCallback.super.getToolMetadata(); + } + + @Override + public String call(String toolInput) { + return handler.apply(toolInput); + } + + @Override + public String call(String toolInput, @Nullable ToolContext toolContext) { + // Skill-scoped tools intentionally don't read from ToolContext — + // the binding is in our captured state. We just forward to the + // single-arg handler so behaviour stays identical regardless of + // whether the framework supplies a context. + return handler.apply(toolInput); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/knowledge/WikiSkillWrapperToolFactory.java b/mateclaw-server/src/main/java/vip/mate/skill/knowledge/WikiSkillWrapperToolFactory.java new file mode 100644 index 00000000..97f66719 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/knowledge/WikiSkillWrapperToolFactory.java @@ -0,0 +1,329 @@ +package vip.mate.skill.knowledge; + +import cn.hutool.json.JSONArray; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.stereotype.Component; +import vip.mate.skill.manifest.SkillManifest; +import vip.mate.wiki.dto.PageSearchResult; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.service.HybridRetriever; +import vip.mate.wiki.service.WikiKnowledgeBaseService; +import vip.mate.wiki.service.WikiPageService; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * RFC-090 §14.4 — generates skill-scoped wrapper {@link ToolCallback}s + * for {@code type: knowledge} skills. + * + *

For each knowledge skill we register exactly three tools: + *

+ * + *

Each callback closes over the resolved {@code kbId} so the LLM + * never has to (and can't accidentally) target a different KB. Multiple + * knowledge skills bound to the same agent each get their own tool + * surface — no ThreadLocal, no ToolContext sneak-through (per §14.4 + * v3.3 校准). + * + *

Output JSON shapes mirror {@code WikiTool}'s @Tool methods so + * downstream prompt logic stays compatible. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WikiSkillWrapperToolFactory { + + private final WikiKnowledgeBaseService kbService; + private final WikiPageService pageService; + private final HybridRetriever hybridRetriever; + private final ObjectMapper objectMapper; + + /** + * Resolve the manifest's {@code knowledge.bind_kb} (a slug or + * numeric id, the spec is loose because KBs don't yet have slugs) + * to a concrete kbId. Returns null when no matching KB exists — + * the caller decides whether that's a fatal install-time error or + * a deferred hint. + */ + public Long resolveKbId(String bindKb) { + if (bindKb == null || bindKb.isBlank()) return null; + String trimmed = bindKb.trim(); + // Numeric id form first (KB picker writes id when slug is absent). + try { + return Long.parseLong(trimmed); + } catch (NumberFormatException ignored) { + /* fall through to name lookup */ + } + // Name match (case-insensitive). + return kbService.listAll().stream() + .filter(kb -> kb.getName() != null && kb.getName().equalsIgnoreCase(trimmed)) + .map(WikiKnowledgeBaseEntity::getId) + .findFirst() + .orElse(null); + } + + /** + * Build the 3 wrapper callbacks for one knowledge skill. Returns an + * empty list when {@code kbId} cannot be resolved — the caller logs + * + skips registration so the skill ends up READY=false rather than + * advertising broken tools. + */ + public List buildWrappers(SkillManifest manifest, Long resolvedKbId) { + List out = new ArrayList<>(3); + if (manifest == null || manifest.getKnowledge() == null || resolvedKbId == null) { + return out; + } + String namePart = sanitize(manifest.getName()); + if (namePart.isBlank()) return out; + String prefix = "kb_" + namePart; + + out.add(buildSearchTool(prefix, resolvedKbId, manifest)); + out.add(buildReadTool(prefix, resolvedKbId, manifest)); + out.add(buildListTool(prefix, resolvedKbId, manifest)); + return out; + } + + /** + * Names of the wrappers a manifest would produce, without actually + * building them. Used by {@link vip.mate.skill.runtime.SkillPackageResolver} + * to populate {@code manifest.allowedTools} so {@code + * ResolvedSkill.getEffectiveAllowedTools} surfaces the right names + * even before the registration side-effect runs. + */ + public List wrapperNames(SkillManifest manifest) { + if (manifest == null || manifest.getName() == null || manifest.getName().isBlank()) { + return List.of(); + } + String prefix = "kb_" + sanitize(manifest.getName()); + return List.of(prefix + "_search", prefix + "_read", prefix + "_list"); + } + + /** + * Tool name slug rule: lowercase, replace any non-[a-z0-9_] with '_'. + * Names that come out of this are stable across resolves so the + * registry diff stays clean. + */ + private static String sanitize(String raw) { + if (raw == null) return ""; + return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_]", "_"); + } + + // ==================== search ==================== + + private ToolCallback buildSearchTool(String prefix, long kbId, SkillManifest manifest) { + String name = prefix + "_search"; + String desc = String.format( + "Search the '%s' knowledge base. Returns up to topK pages with snippet and slug. " + + "Use the slug with %s_read to fetch full content. Always cite page title in answers.", + manifest.getName(), prefix); + String schema = "{" + + "\"type\":\"object\"," + + "\"properties\":{" + + "\"query\":{\"type\":\"string\",\"description\":\"search query\"}," + + "\"mode\":{\"type\":\"string\",\"enum\":[\"keyword\",\"semantic\",\"hybrid\"],\"description\":\"retrieval mode (default: hybrid)\"}," + + "\"topK\":{\"type\":\"integer\",\"description\":\"max results, default 5, max 20\"}" + + "}," + + "\"required\":[\"query\"]" + + "}"; + return new SkillScopedToolCallback(name, desc, schema, input -> doSearch(kbId, input)); + } + + private String doSearch(long kbId, String input) { + try { + JsonNode args = parseArgs(input); + String query = textOrEmpty(args, "query"); + if (query.isEmpty()) return errorJson("query is required"); + String mode = textOrEmpty(args, "mode"); + int topK = args.path("topK").isNumber() + ? Math.min(args.path("topK").asInt(5), 20) : 5; + + List results = hybridRetriever.search(kbId, query, + mode.isEmpty() ? null : mode, topK); + for (PageSearchResult r : results) pageService.trackReference(kbId, r.slug()); + + JSONArray arr = new JSONArray(); + for (PageSearchResult r : results) { + arr.add(JSONUtil.createObj() + .set("slug", r.slug()) + .set("title", r.title()) + .set("snippet", r.snippet()) + .set("matchedBy", r.matchedBy()) + .set("score", r.score())); + } + return JSONUtil.createObj() + .set("kbId", kbId) + .set("count", results.size()) + .set("results", arr) + .toString(); + } catch (Exception e) { + log.warn("kb search wrapper failed: {}", e.getMessage()); + return errorJson("search failed: " + e.getMessage()); + } + } + + // ==================== read ==================== + + private ToolCallback buildReadTool(String prefix, long kbId, SkillManifest manifest) { + String name = prefix + "_read"; + String desc = String.format( + "Read a page from the '%s' knowledge base by slug. Use maxChars to limit response size; " + + "use sectionHeading to extract a single section.", + manifest.getName()); + String schema = "{" + + "\"type\":\"object\"," + + "\"properties\":{" + + "\"slug\":{\"type\":\"string\",\"description\":\"page slug\"}," + + "\"maxChars\":{\"type\":\"integer\",\"description\":\"truncate to this many chars\"}," + + "\"sectionHeading\":{\"type\":\"string\",\"description\":\"only return the section under this heading\"}" + + "}," + + "\"required\":[\"slug\"]" + + "}"; + return new SkillScopedToolCallback(name, desc, schema, input -> doRead(kbId, input)); + } + + private String doRead(long kbId, String input) { + try { + JsonNode args = parseArgs(input); + String slug = textOrEmpty(args, "slug"); + if (slug.isEmpty()) return errorJson("slug is required"); + WikiPageEntity page = pageService.getBySlug(kbId, slug); + if (page == null) return errorJson("page not found: " + slug); + pageService.trackReference(kbId, slug); + + String content = page.getContent() == null ? "" : page.getContent(); + String section = textOrEmpty(args, "sectionHeading"); + if (!section.isEmpty()) content = extractSection(content, section); + int maxChars = args.path("maxChars").isNumber() ? args.path("maxChars").asInt(0) : 0; + if (maxChars > 0 && content.length() > maxChars) { + content = content.substring(0, maxChars) + "\n…(truncated)"; + } + JSONObject result = JSONUtil.createObj() + .set("title", page.getTitle()) + .set("slug", page.getSlug()) + .set("version", page.getVersion()) + .set("content", content); + return result.toString(); + } catch (Exception e) { + log.warn("kb read wrapper failed: {}", e.getMessage()); + return errorJson("read failed: " + e.getMessage()); + } + } + + // ==================== list ==================== + + private ToolCallback buildListTool(String prefix, long kbId, SkillManifest manifest) { + String name = prefix + "_list"; + String desc = String.format( + "List pages in the '%s' knowledge base. Pass an optional 'query' to filter by title keyword.", + manifest.getName()); + String schema = "{" + + "\"type\":\"object\"," + + "\"properties\":{" + + "\"query\":{\"type\":\"string\",\"description\":\"optional title keyword filter\"}" + + "}" + + "}"; + return new SkillScopedToolCallback(name, desc, schema, input -> doList(kbId, input)); + } + + private String doList(long kbId, String input) { + try { + JsonNode args = parseArgs(input); + String query = textOrEmpty(args, "query"); + + List pages; + if (!query.isEmpty()) { + pages = pageService.searchPages(kbId, query).stream() + .filter(p -> !"system".equals(p.getPageType())) + .limit(30).toList(); + } else { + pages = pageService.listSummaries(kbId).stream() + .filter(p -> !"system".equals(p.getPageType())) + .toList(); + } + + JSONArray arr = new JSONArray(); + for (WikiPageEntity p : pages) { + arr.add(JSONUtil.createObj() + .set("slug", p.getSlug()) + .set("title", p.getTitle()) + .set("summary", p.getSummary())); + } + return JSONUtil.createObj() + .set("kbId", kbId) + .set("pageCount", pages.size()) + .set("pages", arr) + .toString(); + } catch (Exception e) { + log.warn("kb list wrapper failed: {}", e.getMessage()); + return errorJson("list failed: " + e.getMessage()); + } + } + + // ==================== helpers ==================== + + private JsonNode parseArgs(String input) throws Exception { + if (input == null || input.isBlank()) { + return objectMapper.createObjectNode(); + } + return objectMapper.readTree(input); + } + + private static String textOrEmpty(JsonNode node, String key) { + JsonNode v = node.get(key); + if (v == null || v.isNull()) return ""; + return v.asText("").trim(); + } + + private static String errorJson(String msg) { + return JSONUtil.createObj().set("error", msg).toString(); + } + + /** + * Same heading-extract shape WikiTool uses — keeps wrapper output + * indistinguishable from native @Tool output for skills that + * already documented their KB workflows. + */ + private static String extractSection(String content, String heading) { + if (content == null || content.isEmpty()) return ""; + String[] lines = content.split("\n"); + StringBuilder out = new StringBuilder(); + boolean capturing = false; + int captureLevel = 0; + for (String line : lines) { + String stripped = line.stripLeading(); + int level = 0; + while (level < stripped.length() && stripped.charAt(level) == '#') level++; + boolean isHeading = level > 0 && level < stripped.length() && stripped.charAt(level) == ' '; + if (isHeading) { + String headingText = stripped.substring(level + 1).trim(); + if (!capturing) { + if (headingText.equalsIgnoreCase(heading.trim())) { + capturing = true; + captureLevel = level; + out.append(line).append('\n'); + } + continue; + } else { + if (level <= captureLevel) { + break; + } + } + } + if (capturing) out.append(line).append('\n'); + } + return out.toString(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java index cfc28da2..2c628298 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java @@ -2,15 +2,19 @@ package vip.mate.skill.runtime; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; +import vip.mate.skill.knowledge.WikiSkillWrapperToolFactory; import vip.mate.skill.manifest.SkillManifest; import vip.mate.skill.manifest.SkillManifestParser; import vip.mate.skill.model.SkillEntity; import vip.mate.skill.repository.SkillMapper; import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.skill.workspace.SkillWorkspaceManager; +import vip.mate.tool.ToolRegistry; import java.nio.file.Files; import java.nio.file.Path; @@ -39,7 +43,6 @@ import java.util.stream.Collectors; */ @Slf4j @Component -@RequiredArgsConstructor public class SkillPackageResolver { private final SkillFrontmatterParser frontmatterParser; @@ -50,6 +53,49 @@ public class SkillPackageResolver { private final ObjectMapper objectMapper; private final SkillWorkspaceManager workspaceManager; private final SkillMapper skillMapper; + /** + * RFC-090 §14.4 — knowledge-skill wrapper tool factory. + * {@code @Lazy} because WikiSkillWrapperToolFactory pulls in Wiki + * services that lag this bean's construction order. + */ + private final WikiSkillWrapperToolFactory wikiWrapperFactory; + /** + * {@code @Lazy} on ToolRegistry — same lazy-resolution loop as + * {@code SkillDependencyChecker}; without this we'd reach for the + * registry before its plugin tools have wired up. + */ + private final ToolRegistry toolRegistry; + + /** + * Tracks which wrapper-tool names we've already registered for + * each skill id, so re-resolves diff-update the registry cleanly + * (deregister stale + register current) without registering twice. + */ + private final java.util.concurrent.ConcurrentHashMap> registeredWrappers + = new java.util.concurrent.ConcurrentHashMap<>(); + + @Autowired + public SkillPackageResolver(SkillFrontmatterParser frontmatterParser, + SkillManifestParser manifestParser, + SkillDirectoryScanner directoryScanner, + SkillSecurityService securityService, + SkillDependencyChecker dependencyChecker, + ObjectMapper objectMapper, + SkillWorkspaceManager workspaceManager, + SkillMapper skillMapper, + @Lazy WikiSkillWrapperToolFactory wikiWrapperFactory, + @Lazy ToolRegistry toolRegistry) { + this.frontmatterParser = frontmatterParser; + this.manifestParser = manifestParser; + this.directoryScanner = directoryScanner; + this.securityService = securityService; + this.dependencyChecker = dependencyChecker; + this.objectMapper = objectMapper; + this.workspaceManager = workspaceManager; + this.skillMapper = skillMapper; + this.wikiWrapperFactory = wikiWrapperFactory; + this.toolRegistry = toolRegistry; + } /** * 解析技能实体为运行时技能包(完整流程) @@ -397,11 +443,20 @@ public class SkillPackageResolver { SkillManifest manifest = manifestParser.parse(content); if (manifest == null) { // No frontmatter at all: leave manifest null and let - // legacy callers continue using dependencyReady. + // legacy callers continue using dependencyReady. Also + // make sure no wrapper tools linger from a prior shape. + deregisterKnowledgeWrappers(resolved.getId()); return; } resolved.setManifest(manifest); + // RFC-090 §14.4 — for knowledge skills, resolve bind_kb + // and (re)register skill-scoped wrapper tools. This must + // happen *before* the feature evaluation below so the + // wrapper names are part of allowedTools when + // getEffectiveAllowedTools() runs. + applyKnowledgeWrappers(resolved, manifest); + // Build requirement lookup for feature checks. Map reqByKey = new LinkedHashMap<>(); for (SkillManifest.RequirementDef r : manifest.getRequires()) { @@ -443,6 +498,103 @@ public class SkillPackageResolver { } } + /** + * RFC-090 §14.4 — register / refresh / deregister wrapper tools + * for a single knowledge skill. + * + *

Behaviour: + *

+ * + *

Wrappers are registered as plugin tools with an availability + * supplier that returns true while the entity stays enabled. That + * way a toggle-off doesn't require an explicit re-resolve to hide + * the tools — the supplier is evaluated each time the agent tool + * set is built. + */ + private void applyKnowledgeWrappers(ResolvedSkill resolved, SkillManifest manifest) { + boolean isKnowledge = "knowledge".equalsIgnoreCase(manifest.getType()) + && manifest.getKnowledge() != null + && manifest.getKnowledge().getBindKb() != null + && !manifest.getKnowledge().getBindKb().isBlank(); + if (!isKnowledge || !resolved.isEnabled()) { + deregisterKnowledgeWrappers(resolved.getId()); + return; + } + + Long kbId = manifest.getKnowledge().getBoundKbId(); + if (kbId == null) { + kbId = wikiWrapperFactory.resolveKbId(manifest.getKnowledge().getBindKb()); + if (kbId == null) { + log.warn("Skill '{}' has type=knowledge but bind_kb '{}' did not resolve to a KB", + resolved.getName(), manifest.getKnowledge().getBindKb()); + deregisterKnowledgeWrappers(resolved.getId()); + // Still surface the resolution failure as a missing + // requirement so the UI shows the skill as + // SETUP_NEEDED rather than READY-but-broken. + resolved.setMissingDependencies(java.util.List.of( + "kb:" + manifest.getKnowledge().getBindKb())); + return; + } + manifest.getKnowledge().setBoundKbId(kbId); + } + + // Fresh build to keep wrapper state in lockstep with the + // current kbId — if the user repointed bind_kb, the old + // wrappers must go. + deregisterKnowledgeWrappers(resolved.getId()); + + java.util.List wrappers = wikiWrapperFactory.buildWrappers(manifest, kbId); + if (wrappers.isEmpty()) { + return; + } + java.util.Set registered = new java.util.LinkedHashSet<>(); + Long entityId = resolved.getId(); + for (ToolCallback cb : wrappers) { + String name = cb.getToolDefinition().name(); + registered.add(name); + // Availability supplier: skill must still resolve to an + // enabled row. Worst case: a disable racing with a tool + // call simply returns "tool unavailable" for one turn. + toolRegistry.registerPluginTool(cb, () -> + entityId != null && resolved.isEnabled()); + } + if (entityId != null) { + registeredWrappers.put(entityId, registered); + } + + // Make wrapper names visible to ResolvedSkill.getEffectiveAllowedTools. + // We *append* rather than replace so a knowledge skill can also + // declare extra allowed-tools alongside the auto-generated KB + // surface (Q9 in §10.2 答 ✅). + java.util.List mergedAllowed = new java.util.ArrayList<>( + manifest.getAllowedTools() == null ? java.util.List.of() : manifest.getAllowedTools()); + for (String wrapperName : wikiWrapperFactory.wrapperNames(manifest)) { + if (!mergedAllowed.contains(wrapperName)) mergedAllowed.add(wrapperName); + } + manifest.setAllowedTools(mergedAllowed); + } + + private void deregisterKnowledgeWrappers(Long skillId) { + if (skillId == null) return; + java.util.Set previous = registeredWrappers.remove(skillId); + if (previous == null || previous.isEmpty()) return; + for (String name : previous) { + try { + toolRegistry.unregisterPluginTool(name); + } catch (Exception e) { + log.debug("unregister wrapper {} failed: {}", name, e.getMessage()); + } + } + } + // ==================== 阶段 4:综合判定 ==================== private void resolveRuntimeAvailability(ResolvedSkill resolved) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java index d5d535fd..41bd13f3 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java @@ -77,6 +77,28 @@ public class SkillRuntimeService { refreshActiveSkills(); } + /** + * 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. + * + *

Rules: + *

    + *
  1. row enabled, runtime resolved, not security-blocked — required
  2. + *
  3. manifest present → at least one feature READY ({@code hasAnyActiveFeature})
  4. + *
  5. manifest absent (legacy SKILL.md) → fall back to + * {@code dependencyReady} so old skills behave unchanged
  6. + *
+ */ + 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(); + } + /** * 获取当前启用的技能列表(运行时视图) */ @@ -101,15 +123,7 @@ public class SkillRuntimeService { List resolved = enabledSkills.stream() .map(packageResolver::resolve) - .filter(ResolvedSkill::isEnabled) - .filter(ResolvedSkill::isRuntimeAvailable) - .filter(s -> !s.isSecurityBlocked()) - // RFC-090 §14.1 — features-aware gate. When a manifest is - // present, require at least one READY feature. Legacy skills - // (no manifest) fall back to the old dependencyReady boolean. - .filter(s -> s.getManifest() == null - ? s.isDependencyReady() - : s.hasAnyActiveFeature()) + .filter(SkillRuntimeService::passesActiveGate) .collect(Collectors.toList()); activeSkillsCache.put(CACHE_KEY, resolved); @@ -169,15 +183,16 @@ public class SkillRuntimeService { public String buildSkillPromptEnhancement(Set boundSkillIds) { List activeSkills; if (boundSkillIds != null) { - // Per-agent 过滤:从全局 enabled skills 中按 ID 过滤 + // Per-agent 过滤:从全局 enabled skills 中按 ID 过滤。RFC-090 + // §14.1 — must use the same features-aware gate as + // refreshActiveSkills() so legacy dependencyReady drift + // doesn't silently let setup-needed manifest skills through + // (or hide partially-ready features that should be visible). List enabledSkills = skillService.listEnabledSkills(); activeSkills = enabledSkills.stream() .filter(s -> boundSkillIds.contains(s.getId())) .map(packageResolver::resolve) - .filter(ResolvedSkill::isEnabled) - .filter(ResolvedSkill::isRuntimeAvailable) - .filter(s -> !s.isSecurityBlocked()) - .filter(ResolvedSkill::isDependencyReady) + .filter(SkillRuntimeService::passesActiveGate) .collect(java.util.stream.Collectors.toList()); } else { activeSkills = getActiveSkills(); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java index 876777ad..84f9bbaf 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java @@ -163,19 +163,39 @@ public class ResolvedSkill { Set out = new LinkedHashSet<>(); Set active = activeFeatures == null ? Set.of() : activeFeatures; - boolean anyFeatureUsedInheritance = false; + + // First pass: collect tools claimed by *any* feature that + // declares an explicit tool subset. Anything in this set is + // owned by a specific feature, so the inherit branch below must + // exclude these unless their owning feature is READY. + // RFC-090 §14.2 / §10.2 Q8 — the LLM must not see tools that + // belong to a SETUP_NEEDED / UNSUPPORTED feature. + Set claimedByAnyFeature = new LinkedHashSet<>(); + Set claimedByActive = new LinkedHashSet<>(); for (SkillManifest.FeatureDef f : manifest.getFeatures()) { - if (!active.contains(f.getId())) continue; - if (f.getTools() == null || f.getTools().isEmpty()) { - // Inherit manifest-level allowed-tools for any active - // feature that doesn't carve out a tool subset. - anyFeatureUsedInheritance = true; - continue; - } - out.addAll(f.getTools()); + List tools = f.getTools(); + if (tools == null || tools.isEmpty()) continue; + claimedByAnyFeature.addAll(tools); + if (active.contains(f.getId())) claimedByActive.addAll(tools); } - if (anyFeatureUsedInheritance) { - out.addAll(base); + out.addAll(claimedByActive); + + // Second pass: any READY feature with no explicit tool list + // means "inherit manifest-level allowed-tools" — but inheritance + // is *fenced*: it doesn't pull in tools that another feature + // already claimed (and isn't itself READY). + boolean anyInheritor = manifest.getFeatures().stream().anyMatch( + f -> active.contains(f.getId()) + && (f.getTools() == null || f.getTools().isEmpty())); + if (anyInheritor) { + for (String t : base) { + if (claimedByAnyFeature.contains(t) && !claimedByActive.contains(t)) { + // This tool belongs to a feature that's not READY — + // inheritance must NOT re-expose it. + continue; + } + out.add(t); + } } return out; }