mateclaw/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java

500 lines
21 KiB
Java

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.
*
* <p>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_<slug>_prompt}) is registered with
* {@link ToolRegistry} so any agent can delegate to that endpoint
* without manual binding.
*
* <p>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.
*
* <p>Lifecycle:
* <ul>
* <li>{@link ApplicationReadyEvent} — initial wrapper registration
* for all enabled endpoints.</li>
* <li>{@link AcpEndpointChangedEvent} — re-sync registrations on
* create / update / toggle / delete.</li>
* <li>{@code list/list-status} entry points rebuild the virtual
* SkillEntity / ResolvedSkill snapshots on demand so the Skills
* page always shows current state.</li>
* </ul>
*
* <p>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<Long, Set<String>> 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<SkillEntity> 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<ResolvedSkill> 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<String> 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<String, String> featureStatuses = new LinkedHashMap<>();
featureStatuses.put("default", ready ? "READY" : "SETUP_NEEDED");
Set<String> active = new LinkedHashSet<>();
if (ready) active.add("default");
List<String> 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_<slug>_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<String> 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<AcpEndpointEntity> 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();
}
}