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:
+ *
+ *
+ *
Aggregates {@code requires-model} from the agent's bound skills'
+ * manifests.
+ *
Compares the union against the primary model's resolved
+ * capability set ({@link ModelCapabilityService#resolve}).
+ *
Logs a clear WARN if a capability is missing — surfacing the
+ * same gap RFC-085's "ready" badge would render in UI.
+ *
+ *
+ *
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