package vip.mate.skill.acp; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.annotation.Lazy; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import vip.mate.acp.event.AcpEndpointChangedEvent; import vip.mate.acp.model.AcpEndpointEntity; import vip.mate.acp.service.AcpDelegationService; import vip.mate.acp.service.AcpEndpointService; import vip.mate.skill.knowledge.SkillScopedToolCallback; import vip.mate.skill.manifest.SkillManifest; import vip.mate.skill.model.SkillEntity; import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.tool.ToolRegistry; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** * RFC-090 §3.2 / §14.4 (parallel) — ACP endpoint → virtual skill bridge. * *

Mirrors {@link vip.mate.skill.mcp.McpSkillBridge}: every enabled * row in {@code mate_acp_endpoint} is automatically surfaced as a * virtual {@link SkillEntity} + {@link ResolvedSkill}, and a wrapper * tool ({@code acp__prompt}) is registered with * {@link ToolRegistry} so any agent can delegate to that endpoint * without manual binding. * *

This solves the "ACP configured as skill cannot be called" * usability bug while keeping MateClaw's skill-card affordance for * endpoint discovery: the user manages endpoints in Settings ▸ ACP * Endpoints, and a card automatically appears on the Skills page — * no per-endpoint SKILL.md authoring required. * *

Lifecycle: *

* *

ID namespace: virtual skill ids use a high sentinel * {@link #VIRTUAL_ID_BASE} different from the MCP bridge's, so the two * id spaces never collide and a callsite can dispatch on which bridge * owns an id without coordination. */ @Slf4j @Service public class AcpSkillBridge { /** * High sentinel for ACP virtual id space. Distinct from * {@code McpSkillBridge.VIRTUAL_ID_BASE} (9e18) so the two virtual * spaces are partitionable by simple range checks. */ public static final long VIRTUAL_ID_BASE = 8_000_000_000_000_000_000L; /** Upper bound, exclusive — anything in [BASE, BASE + 1e17) is ours. */ public static final long VIRTUAL_ID_BOUND = VIRTUAL_ID_BASE + 100_000_000_000_000_000L; private final AcpEndpointService endpointService; private final AcpDelegationService delegationService; private final ObjectMapper objectMapper; private final ToolRegistry toolRegistry; /** * endpointId → set of registered wrapper tool names. Keeps the tool * registry synced with the live endpoint set: when an endpoint is * toggled off/deleted, we know exactly which tools to remove. */ private final ConcurrentHashMap> registeredWrappers = new ConcurrentHashMap<>(); @Autowired public AcpSkillBridge(AcpEndpointService endpointService, AcpDelegationService delegationService, ObjectMapper objectMapper, @Lazy ToolRegistry toolRegistry) { this.endpointService = endpointService; this.delegationService = delegationService; this.objectMapper = objectMapper; this.toolRegistry = toolRegistry; } public static boolean isVirtualAcpSkillId(Long id) { return id != null && id >= VIRTUAL_ID_BASE && id < VIRTUAL_ID_BOUND; } public static Long extractEndpointId(Long virtualId) { if (!isVirtualAcpSkillId(virtualId)) return null; return virtualId - VIRTUAL_ID_BASE; } public static long virtualIdFor(AcpEndpointEntity endpoint) { return VIRTUAL_ID_BASE + endpoint.getId(); } @PostConstruct public void init() { log.info("AcpSkillBridge initialized (virtual ID range: {}+endpointId)", VIRTUAL_ID_BASE); } /** * Initial registration: enumerate enabled endpoints once the * application is fully bootstrapped. {@link ApplicationReadyEvent} * is preferred over {@code @PostConstruct} because the database * bootstrap (Flyway + seed data) finishes only after the context * comes up, and the endpoint table may be empty before that. */ @EventListener(ApplicationReadyEvent.class) public void onReady() { try { int registered = 0; for (AcpEndpointEntity ep : endpointService.listEnabled()) { registerWrappers(ep); registered++; } if (registered > 0) { log.info("AcpSkillBridge: registered wrappers for {} enabled endpoint(s)", registered); } } catch (Exception e) { log.warn("AcpSkillBridge initial registration failed: {}", e.getMessage()); } } /** * Resync wrapper registrations on every endpoint lifecycle event. * Disabled / deleted endpoints have their wrappers removed; enabled * endpoints are re-registered (idempotent — we deregister-then-add * to avoid double registration when the user re-enables a row). */ @EventListener(AcpEndpointChangedEvent.class) public void onEndpointChanged(AcpEndpointChangedEvent event) { Long endpointId = event.endpointId(); if (endpointId == null) return; try { switch (event.type()) { case DELETED -> deregisterWrappers(endpointId); case CREATED, UPDATED, TOGGLED -> { AcpEndpointEntity ep = safeGet(endpointId); if (ep == null || !Boolean.TRUE.equals(ep.getEnabled())) { deregisterWrappers(endpointId); } else { // Idempotent: drop stale set, then build fresh — // covers args/env edits where the wrapper itself // doesn't change but we still want a clean slate. deregisterWrappers(endpointId); registerWrappers(ep); } } } } catch (Exception e) { log.warn("AcpSkillBridge resync for endpoint '{}' ({}) failed: {}", event.name(), event.type(), e.getMessage()); } } /** * Snapshot every enabled endpoint as a virtual {@link SkillEntity}. * Used by the Skills list endpoint; rows are non-persistent and * regenerated on each call. */ public List listAcpDerivedSkillEntities() { return safeListEnabled().stream().map(this::endpointToEntity).toList(); } /** * Snapshot every enabled endpoint as a virtual {@link ResolvedSkill} * with synthesized manifest. Status reflects the last connection * test on the row: OK → READY, ERROR / unknown → SETUP_NEEDED. */ public List listAcpDerivedResolvedSkills() { return safeListEnabled().stream().map(this::endpointToResolved).toList(); } /** * Lookup a single virtual ResolvedSkill by virtual id. Used by the * Skill detail drawer's reverse lookup path. */ public ResolvedSkill findResolvedById(Long virtualId) { Long endpointId = extractEndpointId(virtualId); if (endpointId == null) return null; AcpEndpointEntity ep = safeGet(endpointId); return ep != null && Boolean.TRUE.equals(ep.getEnabled()) ? endpointToResolved(ep) : null; } public SkillEntity findEntityById(Long virtualId) { Long endpointId = extractEndpointId(virtualId); if (endpointId == null) return null; AcpEndpointEntity ep = safeGet(endpointId); return ep != null && Boolean.TRUE.equals(ep.getEnabled()) ? endpointToEntity(ep) : null; } // ==================== Tool registration ==================== private void registerWrappers(AcpEndpointEntity ep) { if (ep == null || !Boolean.TRUE.equals(ep.getEnabled())) return; String slug = slugify(ep.getName()); if (slug.isEmpty()) { log.warn("ACP endpoint id={} has blank name; cannot register wrapper", ep.getId()); return; } String toolName = "acp_" + slug + "_prompt"; String desc = String.format( "Delegate a prompt to the '%s' ACP coding agent. " + "Send a single-string instruction; receive the agent's final reply.%s", ep.getName(), ep.getDescription() == null || ep.getDescription().isBlank() ? "" : " — " + ep.getDescription()); // cwd stays optional in the schema so the LLM doesn't have to // invent a path. The server defaults to the endpoint's bound // workspace base_path when omitted (see AcpRuntimeSupport). String schema = "{" + "\"type\":\"object\"," + "\"properties\":{" + "\"prompt\":{\"type\":\"string\",\"description\":\"the instruction or question to send\"}," + "\"cwd\":{\"type\":\"string\",\"description\":\"optional working directory; " + "defaults to the endpoint's workspace base path when omitted\"}" + "}," + "\"required\":[\"prompt\"]" + "}"; final String endpointName = ep.getName(); final Long endpointId = ep.getId(); SkillScopedToolCallback callback = new SkillScopedToolCallback(toolName, desc, schema, input -> { try { JsonNode args = input == null || input.isBlank() ? objectMapper.createObjectNode() : objectMapper.readTree(input); String userPrompt = args.path("prompt").asText("").trim(); if (userPrompt.isEmpty()) return errorJson("prompt is required"); String cwdHint = args.path("cwd").asText(""); String reply = delegationService.prompt(endpointName, userPrompt, cwdHint == null || cwdHint.isBlank() ? null : cwdHint); JSONObject resp = new JSONObject() .set("endpoint", endpointName) .set("reply", reply); return JSONUtil.toJsonStr(resp); } catch (Exception e) { log.warn("ACP wrapper '{}' failed: {}", toolName, e.getMessage()); return errorJson(e.getMessage() == null ? "delegation failed" : e.getMessage()); } }); // Availability supplier: re-check each agent tool-set build so // a toggle-off without a deregister call still hides the tool. toolRegistry.registerPluginTool(callback, () -> { AcpEndpointEntity live = safeGet(endpointId); return live != null && Boolean.TRUE.equals(live.getEnabled()); }); registeredWrappers.computeIfAbsent(endpointId, k -> ConcurrentHashMap.newKeySet()).add(toolName); log.info("AcpSkillBridge: registered wrapper '{}' for endpoint '{}'", toolName, endpointName); } private void deregisterWrappers(Long endpointId) { Set names = registeredWrappers.remove(endpointId); if (names == null || names.isEmpty()) return; for (String name : names) { try { toolRegistry.unregisterPluginTool(name); } catch (Exception e) { log.debug("Unregister ACP wrapper '{}' failed: {}", name, e.getMessage()); } } log.info("AcpSkillBridge: deregistered {} wrapper(s) for endpoint id={}", names.size(), endpointId); } // ==================== Synthesis ==================== private SkillEntity endpointToEntity(AcpEndpointEntity ep) { SkillEntity s = new SkillEntity(); s.setId(virtualIdFor(ep)); s.setName(slugify(ep.getName())); s.setNameEn(displayName(ep)); s.setNameZh(ep.getDescription() != null && !ep.getDescription().isBlank() ? displayName(ep) : null); s.setDescription(buildDescription(ep)); s.setSkillType("acp"); s.setIcon(iconFor(ep)); s.setVersion("1.0.0"); s.setAuthor("acp-bridge"); s.setEnabled(Boolean.TRUE.equals(ep.getEnabled())); s.setBuiltin(Boolean.TRUE.equals(ep.getBuiltin())); s.setTags("acp"); // Carry the backing endpoint's workspace through to the virtual // SkillEntity so binding-time tenancy checks can compare it against // the agent's workspace. Without this the bridge synthesizes rows // with workspaceId = null and an agent in any workspace could bind // any ACP endpoint regardless of where the endpoint was provisioned. s.setWorkspaceId(ep.getWorkspaceId()); s.setSecurityScanStatus("PASSED"); // ACP endpoints are user-configured external CLIs, not skill scripts s.setConfigJson(buildConfigJson(ep)); s.setManifestJson(serializeManifest(buildManifest(ep))); return s; } private ResolvedSkill endpointToResolved(AcpEndpointEntity ep) { SkillManifest manifest = buildManifest(ep); boolean ok = "OK".equalsIgnoreCase(nullSafe(ep.getLastStatus())); boolean errored = "ERROR".equalsIgnoreCase(nullSafe(ep.getLastStatus())) || (ep.getLastError() != null && !ep.getLastError().isBlank()); // "Unknown" (untested) endpoints are treated as READY so the user // can call them without an explicit Test click — unlike MCP, an // ACP CLI is spawned per call so an untested-but-installed CLI // works fine on first invocation. ERROR keeps SETUP_NEEDED. boolean ready = !errored; Map featureStatuses = new LinkedHashMap<>(); featureStatuses.put("default", ready ? "READY" : "SETUP_NEEDED"); Set active = new LinkedHashSet<>(); if (ready) active.add("default"); List missing = new ArrayList<>(); if (!ready) { missing.add("acp:" + ep.getName() + " (status: " + nullSafe(ep.getLastStatus()) + ")"); } String summary = ok ? "ACP endpoint '" + ep.getName() + "' tested OK" : (errored ? "ACP endpoint '" + ep.getName() + "' last test failed: " + nullSafe(ep.getLastError()) : "ACP endpoint '" + ep.getName() + "' not yet tested — calls will spawn the CLI on demand"); return ResolvedSkill.builder() .id(virtualIdFor(ep)) .name(slugify(ep.getName())) .description(buildDescription(ep)) .content("") // no SKILL.md .source("acp") .skillDir(null) .configuredSkillDir(null) .runtimeAvailable(ready) .resolutionError(ready ? null : nullSafe(ep.getLastError())) .references(Map.of()) .scripts(Map.of()) .enabled(Boolean.TRUE.equals(ep.getEnabled())) .icon(iconFor(ep)) .builtin(Boolean.TRUE.equals(ep.getBuiltin())) .securityBlocked(false) .securitySummary("ACP-derived skill (external CLI; not subject to SKILL.md scanning)") .dependencyReady(ready) .missingDependencies(missing) .dependencySummary(summary) .manifest(manifest) .featureStatuses(featureStatuses) .activeFeatures(active) .build(); } /** * Synthesize a §10.2 minimal manifest from the live endpoint row. * The wrapper tool name {@code acp__prompt} is the single * advertised tool, surfaced via {@code allowedTools} + the default * feature so {@code ResolvedSkill.getEffectiveAllowedTools()} picks * it up the same way as a hand-authored skill manifest. */ private SkillManifest buildManifest(AcpEndpointEntity ep) { String slug = slugify(ep.getName()); String toolName = "acp_" + slug + "_prompt"; List tools = List.of(toolName); SkillManifest.FeatureDef defaultFeature = SkillManifest.FeatureDef.builder() .id("default") .label(displayName(ep)) .requires(List.of("acp:" + ep.getName())) .platforms(List.of()) .tools(tools) .build(); SkillManifest.RequirementDef acpRequirement = SkillManifest.RequirementDef.builder() .key("acp:" + ep.getName()) .type("acp") .check(ep.getName()) .description("ACP endpoint '" + ep.getName() + "' must be enabled and reachable. " + "Configure in Settings ▸ ACP Endpoints.") .build(); SkillManifest.AcpBinding binding = SkillManifest.AcpBinding.builder() .endpoint(ep.getName()) .resolvedEndpointId(ep.getId()) .build(); return SkillManifest.builder() .id(slug) .name(slug) .description(buildDescription(ep)) .icon(iconFor(ep)) .version("1.0.0") .author("acp-bridge") .type("acp") .category("system") .allowedTools(tools) .requires(List.of(acpRequirement)) .features(List.of(defaultFeature)) .acp(binding) .selfEvolution(SkillManifest.SelfEvolution.builder() // Bridged ACP cards don't author LESSONS.md — the // upstream agent owns its own self-evolution. .lessonsEnabled(false) .lessonsMaxEntries(0) .memoryWritesAllowed(true) .build()) .extras(Map.of("acpEndpointId", ep.getId())) .build(); } // ==================== Helpers ==================== private List safeListEnabled() { try { return endpointService.listEnabled(); } catch (Exception e) { log.warn("AcpSkillBridge could not list enabled endpoints: {}", e.getMessage()); return Collections.emptyList(); } } private AcpEndpointEntity safeGet(Long id) { try { return endpointService.get(id); } catch (Exception e) { return null; } } private String slugify(String raw) { if (raw == null) return ""; return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-"); } private String displayName(AcpEndpointEntity ep) { if (ep.getDisplayName() != null && !ep.getDisplayName().isBlank()) return ep.getDisplayName(); return ep.getName() != null ? ep.getName() : "acp-" + ep.getId(); } private String buildDescription(AcpEndpointEntity ep) { if (ep.getDescription() != null && !ep.getDescription().isBlank()) return ep.getDescription(); return "ACP coding agent '" + ep.getName() + "' — delegate prompts via the " + "auto-registered tool. Configure in Settings ▸ ACP Endpoints."; } private String iconFor(AcpEndpointEntity ep) { // Light heuristic — match the most popular ACP runners by name. String n = nullSafe(ep.getName()).toLowerCase(Locale.ROOT); if (n.contains("claude")) return "🟠"; if (n.contains("codex") || n.contains("openai")) return "⚪"; if (n.contains("qwen")) return "🔵"; if (n.contains("gemini") || n.contains("google")) return "🟡"; if (n.contains("opencode")) return "🟢"; return "🤝"; } private String buildConfigJson(AcpEndpointEntity ep) { try { return objectMapper.writeValueAsString(Map.of( "acpEndpointId", ep.getId(), "command", nullSafe(ep.getCommand()), "trusted", Boolean.TRUE.equals(ep.getTrusted()), "source", Map.of("type", "acp"))); } catch (Exception e) { return "{}"; } } private String serializeManifest(SkillManifest manifest) { try { return objectMapper.writeValueAsString(manifest); } catch (Exception e) { return null; } } private static String nullSafe(String s) { return s == null ? "" : s; } private static String errorJson(String msg) { return JSONUtil.createObj().set("error", msg).toString(); } }