mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
fix(skill): knowledge wrappers + provider routing + feature gates
This commit is contained in:
parent
38b66a2416
commit
7ca568c69b
@ -151,17 +151,28 @@ public class AgentGraphBuilder {
|
|||||||
Set<String> boundTools = agentBindingService.getEffectiveToolNames(entity.getId());
|
Set<String> boundTools = agentBindingService.getEffectiveToolNames(entity.getId());
|
||||||
toolSet = toolSet.withAllowedToolsOnly(boundTools); // null = 全局默认
|
toolSet = toolSet.withAllowedToolsOnly(boundTools); // null = 全局默认
|
||||||
|
|
||||||
// 统一使用全局默认模型(AgentEntity.modelName 为历史残留字段,不参与运行时选择)
|
// RFC-090 §9.2 调整 C — pick a primary model that satisfies
|
||||||
ModelConfigEntity runtimeModel;
|
// 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 {
|
try {
|
||||||
runtimeModel = modelConfigService.getDefaultModel();
|
globalDefault = modelConfigService.getDefaultModel();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new MateClawException("err.agent.no_default_model", "无法构建 Agent:请先在「设置 → 模型」中配置并启用默认模型");
|
throw new MateClawException("err.agent.no_default_model", "无法构建 Agent:请先在「设置 → 模型」中配置并启用默认模型");
|
||||||
}
|
}
|
||||||
|
ModelConfigEntity runtimeModel;
|
||||||
// RFC-090 §9.2 调整 C — log a warning when the chosen primary model
|
try {
|
||||||
// doesn't satisfy the union of bound skills' requires-model. This is
|
runtimeModel = providerRouter.selectPrimary(entity.getId(), globalDefault);
|
||||||
// diagnostics only; the chain order isn't rewritten yet.
|
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 {
|
try {
|
||||||
providerRouter.diagnosePrimary(entity.getId(), runtimeModel);
|
providerRouter.diagnosePrimary(entity.getId(), runtimeModel);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@ -730,6 +741,16 @@ public class AgentGraphBuilder {
|
|||||||
log.debug("[LlmFailover] agent={} preferences={} -> chain head reordered", agentId, preferred);
|
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<>();
|
List<vip.mate.llm.failover.FallbackEntry> chain = new ArrayList<>();
|
||||||
for (ModelProviderEntity p : providers) {
|
for (ModelProviderEntity p : providers) {
|
||||||
// Don't put the primary provider's row into the fallback chain — same-instance
|
// Don't put the primary provider's row into the fallback chain — same-instance
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package vip.mate.llm.routing;
|
|||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.context.annotation.Lazy;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import vip.mate.agent.binding.service.AgentBindingService;
|
import vip.mate.agent.binding.service.AgentBindingService;
|
||||||
import vip.mate.llm.model.ModelConfigEntity;
|
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.llm.service.ModelConfigService;
|
||||||
import vip.mate.skill.manifest.SkillManifest;
|
import vip.mate.skill.manifest.SkillManifest;
|
||||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||||
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -133,7 +134,115 @@ public class ProviderRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** {@code @Lazy} hint — kept for symmetry if AgentBindingService is constructed first. */
|
// ==================== chain reorder (RFC-090 §9.2 调整 C) ====================
|
||||||
@Lazy
|
|
||||||
public void noopForLazyHint() { /* no-op */ }
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,15 +2,19 @@ package vip.mate.skill.runtime;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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 org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.skill.knowledge.WikiSkillWrapperToolFactory;
|
||||||
import vip.mate.skill.manifest.SkillManifest;
|
import vip.mate.skill.manifest.SkillManifest;
|
||||||
import vip.mate.skill.manifest.SkillManifestParser;
|
import vip.mate.skill.manifest.SkillManifestParser;
|
||||||
import vip.mate.skill.model.SkillEntity;
|
import vip.mate.skill.model.SkillEntity;
|
||||||
import vip.mate.skill.repository.SkillMapper;
|
import vip.mate.skill.repository.SkillMapper;
|
||||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||||
|
import vip.mate.tool.ToolRegistry;
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@ -39,7 +43,6 @@ import java.util.stream.Collectors;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class SkillPackageResolver {
|
public class SkillPackageResolver {
|
||||||
|
|
||||||
private final SkillFrontmatterParser frontmatterParser;
|
private final SkillFrontmatterParser frontmatterParser;
|
||||||
@ -50,6 +53,49 @@ public class SkillPackageResolver {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final SkillWorkspaceManager workspaceManager;
|
private final SkillWorkspaceManager workspaceManager;
|
||||||
private final SkillMapper skillMapper;
|
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);
|
SkillManifest manifest = manifestParser.parse(content);
|
||||||
if (manifest == null) {
|
if (manifest == null) {
|
||||||
// No frontmatter at all: leave manifest null and let
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
resolved.setManifest(manifest);
|
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.
|
// Build requirement lookup for feature checks.
|
||||||
Map<String, SkillManifest.RequirementDef> reqByKey = new LinkedHashMap<>();
|
Map<String, SkillManifest.RequirementDef> reqByKey = new LinkedHashMap<>();
|
||||||
for (SkillManifest.RequirementDef r : manifest.getRequires()) {
|
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:综合判定 ====================
|
// ==================== 阶段 4:综合判定 ====================
|
||||||
|
|
||||||
private void resolveRuntimeAvailability(ResolvedSkill resolved) {
|
private void resolveRuntimeAvailability(ResolvedSkill resolved) {
|
||||||
|
|||||||
@ -77,6 +77,28 @@ public class SkillRuntimeService {
|
|||||||
refreshActiveSkills();
|
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()
|
List<ResolvedSkill> resolved = enabledSkills.stream()
|
||||||
.map(packageResolver::resolve)
|
.map(packageResolver::resolve)
|
||||||
.filter(ResolvedSkill::isEnabled)
|
.filter(SkillRuntimeService::passesActiveGate)
|
||||||
.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())
|
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
activeSkillsCache.put(CACHE_KEY, resolved);
|
activeSkillsCache.put(CACHE_KEY, resolved);
|
||||||
@ -169,15 +183,16 @@ public class SkillRuntimeService {
|
|||||||
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds) {
|
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds) {
|
||||||
List<ResolvedSkill> activeSkills;
|
List<ResolvedSkill> activeSkills;
|
||||||
if (boundSkillIds != null) {
|
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();
|
List<SkillEntity> enabledSkills = skillService.listEnabledSkills();
|
||||||
activeSkills = enabledSkills.stream()
|
activeSkills = enabledSkills.stream()
|
||||||
.filter(s -> boundSkillIds.contains(s.getId()))
|
.filter(s -> boundSkillIds.contains(s.getId()))
|
||||||
.map(packageResolver::resolve)
|
.map(packageResolver::resolve)
|
||||||
.filter(ResolvedSkill::isEnabled)
|
.filter(SkillRuntimeService::passesActiveGate)
|
||||||
.filter(ResolvedSkill::isRuntimeAvailable)
|
|
||||||
.filter(s -> !s.isSecurityBlocked())
|
|
||||||
.filter(ResolvedSkill::isDependencyReady)
|
|
||||||
.collect(java.util.stream.Collectors.toList());
|
.collect(java.util.stream.Collectors.toList());
|
||||||
} else {
|
} else {
|
||||||
activeSkills = getActiveSkills();
|
activeSkills = getActiveSkills();
|
||||||
|
|||||||
@ -163,19 +163,39 @@ public class ResolvedSkill {
|
|||||||
|
|
||||||
Set<String> out = new LinkedHashSet<>();
|
Set<String> out = new LinkedHashSet<>();
|
||||||
Set<String> active = activeFeatures == null ? Set.of() : activeFeatures;
|
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()) {
|
for (SkillManifest.FeatureDef f : manifest.getFeatures()) {
|
||||||
if (!active.contains(f.getId())) continue;
|
List<String> tools = f.getTools();
|
||||||
if (f.getTools() == null || f.getTools().isEmpty()) {
|
if (tools == null || tools.isEmpty()) continue;
|
||||||
// Inherit manifest-level allowed-tools for any active
|
claimedByAnyFeature.addAll(tools);
|
||||||
// feature that doesn't carve out a tool subset.
|
if (active.contains(f.getId())) claimedByActive.addAll(tools);
|
||||||
anyFeatureUsedInheritance = true;
|
}
|
||||||
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
out.addAll(f.getTools());
|
out.add(t);
|
||||||
}
|
}
|
||||||
if (anyFeatureUsedInheritance) {
|
|
||||||
out.addAll(base);
|
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user