mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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());
|
||||
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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.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) {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user