fix(skill): knowledge wrappers + provider routing + feature gates

This commit is contained in:
matevip 2026-05-01 09:49:37 +08:00
parent 38b66a2416
commit 7ca568c69b
7 changed files with 751 additions and 39 deletions

View File

@ -151,17 +151,28 @@ public class AgentGraphBuilder {
Set<String> 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<vip.mate.llm.failover.FallbackEntry> chain = new ArrayList<>();
for (ModelProviderEntity p : providers) {
// Don't put the primary provider's row into the fallback chain same-instance

View File

@ -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.
*
* <p>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<ModelProviderEntity> reorderForCapabilities(Long agentId,
List<ModelProviderEntity> ordered) {
if (ordered == null || ordered.isEmpty()) return ordered;
Set<String> needs = aggregateModelNeeds(agentId);
if (needs.isEmpty()) return ordered;
Set<Modality> 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<ModelProviderEntity> satisfying = new ArrayList<>();
List<ModelProviderEntity> 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<ModelProviderEntity> 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.
*
* <p>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<String> needs = aggregateModelNeeds(agentId);
if (needs.isEmpty()) return globalDefault;
Set<Modality> 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<Modality> resolved = capabilityService.resolve(
globalDefault.getModelName(), globalDefault.getModalities());
if (resolved.containsAll(requiredModalities)) return globalDefault;
}
List<String> preferred = bindingService.getPreferredProviderIds(agentId);
for (String providerId : preferred) {
ModelConfigEntity candidate = pickProviderDefault(providerId);
if (candidate == null) continue;
EnumSet<Modality> 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<Modality> needs) {
ModelConfigEntity def = pickProviderDefault(provider.getProviderId());
if (def == null) return false;
return capabilityService.resolve(def.getModelName(), def.getModalities()).containsAll(needs);
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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<String, String> handler;
public SkillScopedToolCallback(String name,
String description,
String inputSchema,
Function<String, String> 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);
}
}

View File

@ -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.
*
* <p>For each knowledge skill we register exactly three tools:
* <ul>
* <li>{@code kb_<slug>_search} semantic / keyword / hybrid search</li>
* <li>{@code kb_<slug>_read} fetch a page by slug</li>
* <li>{@code kb_<slug>_list} enumerate pages, optionally filtered</li>
* </ul>
*
* <p>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 校准).
*
* <p>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<ToolCallback> buildWrappers(SkillManifest manifest, Long resolvedKbId) {
List<ToolCallback> 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<String> 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<PageSearchResult> 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<WikiPageEntity> 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();
}
}

View File

@ -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<Long, java.util.Set<String>> 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<String, SkillManifest.RequirementDef> 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.
*
* <p>Behaviour:
* <ul>
* <li>{@code type != knowledge} or skill disabled deregister
* any prior wrappers, leave {@code allowedTools} alone.</li>
* <li>{@code bind_kb} unresolvable deregister + emit warning;
* feature evaluation will then mark the skill SETUP_NEEDED.</li>
* <li>Otherwise: deregister prior set, register fresh, append
* wrapper names to {@code manifest.allowedTools} so
* {@code getEffectiveAllowedTools()} surfaces them.</li>
* </ul>
*
* <p>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<ToolCallback> wrappers = wikiWrapperFactory.buildWrappers(manifest, kbId);
if (wrappers.isEmpty()) {
return;
}
java.util.Set<String> 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<String> 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<String> 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) {

View File

@ -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.
*
* <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();
}
/**
* 获取当前启用的技能列表运行时视图
*/
@ -101,15 +123,7 @@ public class SkillRuntimeService {
List<ResolvedSkill> 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<Long> boundSkillIds) {
List<ResolvedSkill> 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<SkillEntity> 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();

View File

@ -163,19 +163,39 @@ public class ResolvedSkill {
Set<String> out = new LinkedHashSet<>();
Set<String> 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<String> claimedByAnyFeature = new LinkedHashSet<>();
Set<String> 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<String> 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;
}