From d927521d51723298e529f746e19def56ff60d92b Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 1 May 2026 09:48:59 +0800 Subject: [PATCH] feat(skill): install/uninstall split + Requirements API + provider router --- .../vip/mate/agent/AgentGraphBuilder.java | 11 ++ .../vip/mate/llm/routing/ProviderRouter.java | 139 ++++++++++++++++++ .../skill/controller/SkillController.java | 59 ++++++++ mateclaw-ui/src/views/SkillMarket.vue | 16 +- 4 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 880d361a..be613b9b 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -59,6 +59,7 @@ import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.model.ModelFamily; import vip.mate.llm.model.ModelProtocol; import vip.mate.llm.model.ModelProviderEntity; +import vip.mate.llm.routing.ProviderRouter; import vip.mate.llm.service.ModelConfigService; import vip.mate.llm.service.ModelProviderService; import vip.mate.planning.service.PlanningService; @@ -101,6 +102,7 @@ public class AgentGraphBuilder { private final ModelConfigService modelConfigService; private final ModelProviderService modelProviderService; private final vip.mate.llm.service.ModelCapabilityService modelCapabilityService; + private final ProviderRouter providerRouter; private final PlanningService planningService; private final ToolGuardService toolGuardService; private final vip.mate.tool.guard.service.ToolGuardConfigService toolGuardConfigService; @@ -157,6 +159,15 @@ public class AgentGraphBuilder { 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. + try { + providerRouter.diagnosePrimary(entity.getId(), runtimeModel); + } catch (Exception e) { + log.debug("[ProviderRouter] diagnostic failed: {}", e.getMessage()); + } + ModelProviderEntity provider; try { provider = modelProviderService.getProviderConfig(runtimeModel.getProvider()); diff --git a/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java b/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java new file mode 100644 index 00000000..383f4f3e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java @@ -0,0 +1,139 @@ +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; +import vip.mate.llm.service.ModelCapabilityService; +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.skill.runtime.model.ResolvedSkill; + +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * RFC-090 §9.2 调整 C — diagnostics-first ProviderRouter. + * + *

This first iteration does not yet rewrite the fallback chain order + * (the existing {@code AgentBindingService.getPreferredProviderIds} + + * {@link vip.mate.agent.AgentGraphBuilder#buildFallbackChain} flow is + * already in place). Instead it: + * + *

    + *
  1. Aggregates {@code requires-model} from the agent's bound skills' + * manifests.
  2. + *
  3. Compares the union against the primary model's resolved + * capability set ({@link ModelCapabilityService#resolve}).
  4. + *
  5. Logs a clear WARN if a capability is missing — surfacing the + * same gap RFC-085's "ready" badge would render in UI.
  6. + *
+ * + *

Promoting this to actual chain re-ordering (i.e. "prefer providers + * that satisfy modelNeeds") is straightforward once we have the data + * for it: add a phase between {@code reorderByPreferences} and the + * model build loop. That phase is intentionally not in this commit so + * we can ship the diagnostics path independently and watch it in dev. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ProviderRouter { + + private final SkillRuntimeService skillRuntimeService; + private final AgentBindingService bindingService; + private final ModelCapabilityService capabilityService; + private final ModelConfigService modelConfigService; + + /** + * Compute the union of capability requirements declared by the + * skills bound to {@code agentId}. Returns an empty set when no + * bindings exist or no skill declares {@code requires-model}. + */ + public Set aggregateModelNeeds(Long agentId) { + if (agentId == null || skillRuntimeService == null) return Set.of(); + Set boundSkillIds = bindingService.getBoundSkillIds(agentId); + if (boundSkillIds == null || boundSkillIds.isEmpty()) return Set.of(); + List all = skillRuntimeService.resolveAllSkillsStatus(); + Set needs = new LinkedHashSet<>(); + for (ResolvedSkill r : all) { + if (r == null || r.getId() == null) continue; + if (!boundSkillIds.contains(r.getId())) continue; + SkillManifest m = r.getManifest(); + if (m == null) continue; + List declared = m.getRequiresModel(); + if (declared == null || declared.isEmpty()) continue; + needs.addAll(declared); + } + return needs; + } + + /** + * Diagnostic check: does the chosen primary model satisfy every + * skill-declared capability? Logs a single WARN per gap. + * + *

Intentionally never throws — this is observability, not policy. + */ + public void diagnosePrimary(Long agentId, ModelConfigEntity primary) { + if (primary == null || agentId == null) return; + Set needs = aggregateModelNeeds(agentId); + if (needs.isEmpty()) return; + EnumSet resolved = capabilityService.resolve( + primary.getModelName(), primary.getModalities()); + for (String need : needs) { + Modality required = mapToModality(need); + if (required == null) continue; // capability we can't translate (e.g. function_calling) — skip + if (!resolved.contains(required)) { + log.warn("[ProviderRouter] agent={} primary={}/{} missing capability '{}' " + + "required by bound skills (resolved: {})", + agentId, primary.getProvider(), primary.getModelName(), + need, resolved); + } + } + } + + /** + * Translate a manifest {@code requires-model} token to a + * {@link Modality}. Tokens that don't map (e.g. {@code function_calling}, + * {@code long_context_100k}) return null — the caller skips diagnostics + * for them rather than emit a noisy warning we can't act on yet. + */ + private Modality mapToModality(String need) { + if (need == null) return null; + String n = need.trim().toLowerCase(); + return switch (n) { + case "vision", "image", "vl" -> Modality.VISION; + case "video" -> Modality.VIDEO; + case "audio", "speech" -> Modality.AUDIO; + default -> null; + }; + } + + /** + * Convenience for tests / health checks: return the current model's + * capability resolution as a structured summary (provider/model → + * modalities). + */ + public String summarize(Long agentId) { + try { + ModelConfigEntity primary = modelConfigService.getDefaultModel(); + EnumSet resolved = capabilityService.resolve( + primary.getModelName(), primary.getModalities()); + Set needs = aggregateModelNeeds(agentId); + return String.format("primary=%s/%s modalities=%s needs=%s", + primary.getProvider(), primary.getModelName(), resolved, needs); + } catch (Exception e) { + return "ProviderRouter summary unavailable: " + e.getMessage(); + } + } + + /** {@code @Lazy} hint — kept for symmetry if AgentBindingService is constructed first. */ + @Lazy + public void noopForLazyHint() { /* no-op */ } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index b9d7b157..f11148e7 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -6,13 +6,17 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; +import vip.mate.skill.manifest.SkillManifest; import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.runtime.SkillDependencyChecker; import vip.mate.skill.service.SkillService; import vip.mate.skill.synthesis.SkillSynthesisService; import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.skill.workspace.SkillWorkspaceManager; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -34,6 +38,7 @@ public class SkillController { private final SkillRuntimeService skillRuntimeService; private final SkillWorkspaceManager workspaceManager; private final SkillSynthesisService synthesisService; + private final SkillDependencyChecker dependencyChecker; @Operation(summary = "获取技能分页列表(RFC-042 §2.1)") @GetMapping @@ -150,6 +155,60 @@ public class SkillController { "resynced", resynced)); } + // ==================== Requirements API (RFC-090 §7) ==================== + + /** + * RFC-090 §7 — pre-flight check: returns the per-requirement status + * for the skill so the install dialog and the detail drawer can render + * "✓ ffmpeg detected / ✗ groq_key missing" rows. + * + *

Calls the same {@link SkillDependencyChecker} the runtime uses, + * so the UI never disagrees with the runtime gate. + */ + @Operation(summary = "Pre-flight requirement statuses for a skill (RFC-090)") + @GetMapping("/{id}/requirements") + public R> requirements(@PathVariable Long id) { + ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream() + .filter(r -> r != null && id.equals(r.getId())) + .findFirst() + .orElse(null); + if (resolved == null) { + return R.fail("Skill not found: " + id); + } + SkillManifest manifest = resolved.getManifest(); + if (manifest == null) { + // Legacy skill — fall back to dependency summary already on the resolved view. + return R.ok(Map.of( + "allMet", resolved.isDependencyReady(), + "statuses", List.of(), + "summary", resolved.getDependencySummary() != null ? resolved.getDependencySummary() : "" + )); + } + + List> statuses = new ArrayList<>(); + boolean allMet = true; + for (SkillManifest.RequirementDef req : manifest.getRequires()) { + SkillDependencyChecker.RequirementStatus st = dependencyChecker.checkRequirement(req); + boolean satisfied = st == SkillDependencyChecker.RequirementStatus.SATISFIED; + if (!satisfied && !req.isOptional()) allMet = false; + Map row = new LinkedHashMap<>(); + row.put("key", req.getKey()); + row.put("type", req.getType()); + row.put("description", req.getDescription()); + row.put("optional", req.isOptional()); + row.put("status", st.name()); + row.put("satisfied", satisfied); + row.put("installCommands", req.getInstall()); + statuses.add(row); + } + return R.ok(Map.of( + "allMet", allMet, + "statuses", statuses, + "featureStatuses", resolved.getFeatureStatuses(), + "activeFeatures", resolved.getActiveFeatures() + )); + } + // ==================== Synthesis API (RFC-023) ==================== @Operation(summary = "从对话历史合成 Skill(RFC-023)") diff --git a/mateclaw-ui/src/views/SkillMarket.vue b/mateclaw-ui/src/views/SkillMarket.vue index 6510f2cf..17e0c300 100644 --- a/mateclaw-ui/src/views/SkillMarket.vue +++ b/mateclaw-ui/src/views/SkillMarket.vue @@ -196,7 +196,7 @@ {{ t('skills.actions.configure') }} -