diff --git a/mateclaw-server/src/main/java/vip/mate/acp/client/AcpStdioClient.java b/mateclaw-server/src/main/java/vip/mate/acp/client/AcpStdioClient.java index ddd02f3e..788b7b49 100644 --- a/mateclaw-server/src/main/java/vip/mate/acp/client/AcpStdioClient.java +++ b/mateclaw-server/src/main/java/vip/mate/acp/client/AcpStdioClient.java @@ -18,6 +18,8 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; +import java.util.function.Function; /** * RFC-090 Phase 7 — minimal Java ACP (Agent Communication Protocol) @@ -61,6 +63,28 @@ public class AcpStdioClient implements AutoCloseable { private final Map> pending = new ConcurrentHashMap<>(); private volatile boolean closed = false; + /** + * RFC-090 Phase 7b — invoked when the agent sends a JSON-RPC + * notification (no id). Notification objects passed in have shape + * {@code {jsonrpc, method, params}}; the most common is + * {@code session/update} carrying agent message chunks. + * + *

Default no-op so existing test-only callers don't need to set + * a handler. {@link AcpDelegationService} installs an accumulator + * that scrapes {@code agent_message_chunk} text into a + * {@code StringBuilder}. + */ + private volatile Consumer notificationHandler = msg -> { /* drop */ }; + + /** + * RFC-090 Phase 7b — invoked when the agent sends a JSON-RPC + * request (has id). The handler returns the JSON-RPC + * {@code result} object (or null to send back -32601 method-not- + * implemented). Used for {@code session/request_permission}; + * trusted endpoints auto-allow, untrusted ones cancel. + */ + private volatile Function requestHandler = msg -> null; + private AcpStdioClient(ObjectMapper mapper, Process process) { this.mapper = mapper; this.process = process; @@ -216,7 +240,11 @@ public class AcpStdioClient implements AutoCloseable { private void routeMessage(JsonNode msg) { JsonNode idNode = msg.get("id"); - if (idNode != null && idNode.isNumber()) { + boolean hasId = idNode != null && !idNode.isNull(); + boolean hasMethod = msg.has("method"); + + // (1) Response to one of *our* outbound requests. + if (hasId && idNode.isNumber() && !hasMethod) { long id = idNode.asLong(); CompletableFuture future = pending.remove(id); if (future != null) { @@ -230,31 +258,70 @@ public class AcpStdioClient implements AutoCloseable { return; } } - // Server-initiated request or notification — for the test path - // we only need to politely decline. A future bidirectional - // session loop will dispatch these to handlers. - if (msg.has("method") && msg.has("id")) { - // Reply with a "method not implemented" so the agent doesn't hang. + + // (2) Server-initiated request — has both method and id. + if (hasMethod && hasId) { + JsonNode result = null; try { - ObjectNode reply = mapper.createObjectNode(); - reply.put("jsonrpc", "2.0"); - reply.set("id", msg.get("id")); - ObjectNode error = mapper.createObjectNode(); - error.put("code", -32601); - error.put("message", "Method not implemented in test client: " - + msg.path("method").asText("")); - reply.set("error", error); - synchronized (stdin) { - stdin.write(mapper.writeValueAsString(reply)); - stdin.write('\n'); - stdin.flush(); - } - } catch (IOException e) { - log.debug("ACP failed to reply to server-initiated request: {}", e.getMessage()); + result = requestHandler.apply(msg); + } catch (Exception e) { + log.warn("ACP requestHandler threw on method '{}': {}", + msg.path("method").asText(""), e.getMessage()); + } + sendReplyTo(idNode, result, msg.path("method").asText("")); + return; + } + + // (3) Notification — has method but no id. + if (hasMethod) { + try { + notificationHandler.accept(msg); + } catch (Exception e) { + log.warn("ACP notificationHandler threw on method '{}': {}", + msg.path("method").asText(""), e.getMessage()); } } } + private void sendReplyTo(JsonNode idNode, JsonNode result, String method) { + try { + ObjectNode reply = mapper.createObjectNode(); + reply.put("jsonrpc", "2.0"); + reply.set("id", idNode); + if (result != null) { + reply.set("result", result); + } else { + ObjectNode error = mapper.createObjectNode(); + error.put("code", -32601); + error.put("message", "Method not implemented: " + method); + reply.set("error", error); + } + synchronized (stdin) { + stdin.write(mapper.writeValueAsString(reply)); + stdin.write('\n'); + stdin.flush(); + } + } catch (IOException e) { + log.debug("ACP failed to reply to server-initiated request '{}': {}", method, e.getMessage()); + } + } + + /** + * Replace the notification handler. Pass {@code null} to fall back + * to the no-op default. + */ + public void setNotificationHandler(Consumer handler) { + this.notificationHandler = handler != null ? handler : msg -> {}; + } + + /** + * Replace the server-request handler. Pass {@code null} to fall + * back to the default which returns -32601 for every method. + */ + public void setRequestHandler(Function handler) { + this.requestHandler = handler != null ? handler : msg -> null; + } + @Override public void close() { closed = true; diff --git a/mateclaw-server/src/main/java/vip/mate/acp/service/AcpDelegationService.java b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpDelegationService.java new file mode 100644 index 00000000..02d591e0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpDelegationService.java @@ -0,0 +1,227 @@ +package vip.mate.acp.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.acp.client.AcpStdioClient; +import vip.mate.acp.model.AcpEndpointEntity; +import vip.mate.exception.MateClawException; + +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +/** + * RFC-090 Phase 7b — fire-and-forget delegation to an external ACP + * agent. + * + *

One {@link #prompt(String, String, String)} call: + *

    + *
  1. Looks up the endpoint row, refuses if disabled or undefined.
  2. + *
  3. Spawns a fresh {@link AcpStdioClient} (no session caching in + * v1 — stateless tool calls keep failure surface small; + * multi-turn caching can be a follow-up RFC).
  4. + *
  5. Runs {@code initialize → session/new → session/prompt}.
  6. + *
  7. Accumulates {@code agent_message_chunk} text from + * {@code session/update} notifications into the response.
  8. + *
  9. Auto-allows or cancels {@code session/request_permission} + * based on the endpoint's {@code trusted} flag — untrusted + * endpoints reject every permission request, surfacing a + * transparent "this endpoint can't be used non-interactively" + * error to the LLM caller.
  10. + *
  11. Returns the accumulated text or a JSON error blob on failure.
  12. + *
+ * + *

The streaming surface (chunk-by-chunk relay back through MateClaw's + * own SSE stream) is intentionally not done yet — the wrapper tool is + * synchronous so it composes cleanly with the existing ReAct graph. + * When we want native streaming, we'll add a second method that takes + * an {@code Sinks.Many}. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AcpDelegationService { + + /** Hard ceiling on a single ACP delegation. Long enough for a + * multi-turn coding session, short enough that a hung agent can't + * permanently block an LLM tool call. */ + private static final Duration PROMPT_TIMEOUT = Duration.ofMinutes(5); + + private static final long INITIALIZE_TIMEOUT_MS = 15_000L; + private static final long SESSION_NEW_TIMEOUT_MS = 10_000L; + + private final ObjectMapper objectMapper; + private final AcpEndpointService endpointService; + + /** + * Run a one-shot ACP prompt against {@code endpointName}. Returns + * the agent's accumulated reply text. Throws + * {@link MateClawException} for configuration / runtime errors so + * the caller (typically a wrapper tool) can serialize a friendly + * JSON error. + */ + public String prompt(String endpointName, String userPrompt, String cwdHint) { + if (endpointName == null || endpointName.isBlank()) { + throw new MateClawException("err.acp.endpoint_required", + "ACP endpoint name is required"); + } + if (userPrompt == null || userPrompt.isBlank()) { + throw new MateClawException("err.acp.prompt_required", + "ACP prompt is required"); + } + + AcpEndpointEntity endpoint = endpointService.findByName(endpointName); + if (endpoint == null) { + throw new MateClawException("err.acp.endpoint_not_found", + "ACP endpoint not found: " + endpointName); + } + if (!Boolean.TRUE.equals(endpoint.getEnabled())) { + throw new MateClawException("err.acp.endpoint_disabled", + "ACP endpoint '" + endpointName + "' is disabled — enable it in Settings ▸ ACP Endpoints"); + } + + List args = endpointService.parseArgs(endpoint); + Map env = endpointService.parseEnv(endpoint); + boolean trusted = !Boolean.FALSE.equals(endpoint.getTrusted()); + + StringBuilder accumulator = new StringBuilder(); + AcpStdioClient client; + try { + client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(), + args, env, cwdHint); + } catch (IOException e) { + throw new MateClawException("err.acp.spawn_failed", + "Failed to spawn ACP agent '" + endpointName + "': " + e.getMessage()); + } + + try (AcpStdioClient autoClose = client) { + wireHandlers(autoClose, accumulator, trusted, endpointName); + + JsonNode initResp = autoClose.initialize(INITIALIZE_TIMEOUT_MS); + if (initResp == null || initResp.path("protocolVersion").asInt(-1) + != AcpStdioClient.PROTOCOL_VERSION) { + throw new MateClawException("err.acp.protocol_mismatch", + "ACP protocol mismatch with endpoint '" + endpointName + "'"); + } + + JsonNode session = autoClose.newSession(cwdHint, SESSION_NEW_TIMEOUT_MS); + String sessionId = session == null ? null : session.path("sessionId").asText(""); + if (sessionId == null || sessionId.isBlank()) { + throw new MateClawException("err.acp.session_failed", + "ACP session/new returned no sessionId for '" + endpointName + "'"); + } + + ObjectNode promptParams = objectMapper.createObjectNode(); + promptParams.put("sessionId", sessionId); + promptParams.set("prompt", buildPromptArray(userPrompt)); + autoClose.sendRequest("session/prompt", promptParams, PROMPT_TIMEOUT.toMillis()); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) Thread.currentThread().interrupt(); + log.warn("ACP delegation failed for endpoint '{}': {}", endpointName, e.getMessage()); + throw new MateClawException("err.acp.delegation_failed", + "ACP delegation to '" + endpointName + "' failed: " + e.getMessage()); + } + + return accumulator.toString().trim(); + } + + private void wireHandlers(AcpStdioClient client, StringBuilder buf, + boolean trusted, String endpointName) { + // Notifications carry session/update messages; agent_message_chunk + // is what we accumulate. Other update kinds (tool_call_*, plan, + // current_mode) are observed but not relayed in v1. + client.setNotificationHandler(msg -> { + String method = msg.path("method").asText(""); + if (!"session/update".equals(method)) return; + JsonNode update = msg.path("params").path("update"); + if (update.isMissingNode() || update.isNull()) return; + String type = update.path("sessionUpdate").asText( + update.path("type").asText("")); + if ("agent_message_chunk".equals(type) || "agent-message-chunk".equals(type)) { + String text = extractText(update.path("content")); + if (!text.isEmpty()) buf.append(text); + } + }); + + // Permission requests: trusted endpoints auto-allow the FIRST + // option (which Zed-style agents make the "allow" choice); + // untrusted refuse every request explicitly so the agent + // exits cleanly instead of hanging. + client.setRequestHandler(msg -> { + String method = msg.path("method").asText(""); + if (!"session/request_permission".equals(method)) return null; + JsonNode params = msg.path("params"); + if (!trusted) { + log.info("[ACP] declining permission for untrusted endpoint '{}'", endpointName); + return cancelledOutcome(); + } + JsonNode options = params.path("options"); + String optionId = ""; + if (options.isArray() && options.size() > 0) { + JsonNode first = options.get(0); + optionId = first.path("optionId").asText(first.path("id").asText("")); + } + if (optionId.isEmpty()) { + return cancelledOutcome(); + } + return selectedOutcome(optionId); + }); + } + + private JsonNode buildPromptArray(String text) { + // Spring AI / Zed ACP prompt format: array of content blocks. + // For now we only emit a single text block; future iterations + // can attach images / file references via additional blocks. + var arr = objectMapper.createArrayNode(); + ObjectNode block = objectMapper.createObjectNode(); + block.put("type", "text"); + block.put("text", text); + arr.add(block); + return arr; + } + + /** + * Extract plain text from an ACP {@code content} field. The shape + * varies between agents — Zed uses {@code [{type:"text",text:"..."}]}, + * some emit a single object, others nest in {@code resource.text}. + * Mirror QwenPaw's tolerant extractor. + */ + private String extractText(JsonNode content) { + if (content == null || content.isNull()) return ""; + if (content.isArray()) { + StringBuilder sb = new StringBuilder(); + for (JsonNode item : content) sb.append(extractText(item)); + return sb.toString(); + } + JsonNode text = content.get("text"); + if (text != null && text.isTextual()) return text.asText(""); + JsonNode resource = content.get("resource"); + if (resource != null) { + JsonNode rt = resource.get("text"); + if (rt != null && rt.isTextual()) return rt.asText(""); + } + return ""; + } + + private ObjectNode selectedOutcome(String optionId) { + ObjectNode result = objectMapper.createObjectNode(); + ObjectNode outcome = objectMapper.createObjectNode(); + outcome.put("outcome", "selected"); + outcome.put("optionId", optionId); + result.set("outcome", outcome); + return result; + } + + private ObjectNode cancelledOutcome() { + ObjectNode result = objectMapper.createObjectNode(); + ObjectNode outcome = objectMapper.createObjectNode(); + outcome.put("outcome", "cancelled"); + result.set("outcome", outcome); + return result; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/knowledge/AcpSkillWrapperToolFactory.java b/mateclaw-server/src/main/java/vip/mate/skill/knowledge/AcpSkillWrapperToolFactory.java new file mode 100644 index 00000000..6bc7e1fd --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/knowledge/AcpSkillWrapperToolFactory.java @@ -0,0 +1,135 @@ +package vip.mate.skill.knowledge; + +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.acp.model.AcpEndpointEntity; +import vip.mate.acp.service.AcpDelegationService; +import vip.mate.acp.service.AcpEndpointService; +import vip.mate.skill.manifest.SkillManifest; + +import java.util.List; +import java.util.Locale; + +/** + * RFC-090 §14.4 (parallel) — wrapper tool factory for {@code type=acp} + * skills. + * + *

For every {@code type=acp} skill, registers exactly one tool: + * {@code acp___prompt(prompt)}. The wrapper closes + * over the resolved endpoint id so the LLM never has to (and can't + * accidentally) target a different endpoint. + * + *

The wrapper is synchronous: it sends the prompt to the upstream + * ACP agent, accumulates streamed text, and returns the joined reply. + * No multi-turn session caching; each call is fresh. See + * {@link AcpDelegationService} for the rationale. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AcpSkillWrapperToolFactory { + + private final AcpEndpointService endpointService; + private final AcpDelegationService delegationService; + private final ObjectMapper objectMapper; + + /** + * Resolve a manifest's {@code acp.endpoint} (slug) to a row id. + * Returns null when the slug doesn't match any registered endpoint. + */ + public Long resolveEndpointId(String endpointName) { + if (endpointName == null || endpointName.isBlank()) return null; + AcpEndpointEntity ep = endpointService.findByName(endpointName.trim()); + return ep == null ? null : ep.getId(); + } + + /** + * Build the wrapper callback for one ACP skill. Returns an empty + * list when the manifest doesn't declare an ACP binding. + */ + public List buildWrappers(SkillManifest manifest) { + if (manifest == null || manifest.getAcp() == null + || manifest.getAcp().getEndpoint() == null + || manifest.getAcp().getEndpoint().isBlank()) { + return List.of(); + } + String slug = sanitize(manifest.getName()); + String endpointSlug = sanitize(manifest.getAcp().getEndpoint()); + if (slug.isBlank() || endpointSlug.isBlank()) return List.of(); + + String name = "acp_" + endpointSlug + "_" + slug + "_prompt"; + String displayName = manifest.getName() != null ? manifest.getName() : slug; + String desc = String.format( + "Delegate a prompt to the '%s' coding agent (skill: %s). " + + "Send a single-string instruction; receive the agent's final reply.", + manifest.getAcp().getEndpoint(), displayName); + String schema = "{" + + "\"type\":\"object\"," + + "\"properties\":{" + + "\"prompt\":{\"type\":\"string\",\"description\":\"the instruction or question to send\"}" + + "}," + + "\"required\":[\"prompt\"]" + + "}"; + + String endpointName = manifest.getAcp().getEndpoint(); + String cwd = manifest.getAcp().getCwd(); + String systemPrefix = manifest.getAcp().getSystemPrefix(); + + return List.of(new SkillScopedToolCallback(name, 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 composedPrompt = systemPrefix == null || systemPrefix.isBlank() + ? userPrompt + : systemPrefix.trim() + "\n\n" + userPrompt; + String reply = delegationService.prompt(endpointName, composedPrompt, cwd); + JSONObject resp = new JSONObject() + .set("endpoint", endpointName) + .set("skill", manifest.getName()) + .set("reply", reply); + return JSONUtil.toJsonStr(resp); + } catch (Exception e) { + log.warn("acp wrapper '{}' failed: {}", name, e.getMessage()); + return errorJson(e.getMessage() == null ? "delegation failed" : e.getMessage()); + } + })); + } + + /** + * Names the wrappers a manifest *would* produce, without actually + * building them. Used by {@link vip.mate.skill.runtime.SkillPackageResolver} + * to populate {@code manifest.allowedTools} so + * {@link vip.mate.skill.runtime.model.ResolvedSkill#getEffectiveAllowedTools()} + * surfaces the wrapper name even before registration. + */ + public List wrapperNames(SkillManifest manifest) { + if (manifest == null || manifest.getAcp() == null + || manifest.getAcp().getEndpoint() == null + || manifest.getName() == null) { + return List.of(); + } + String slug = sanitize(manifest.getName()); + String endpointSlug = sanitize(manifest.getAcp().getEndpoint()); + if (slug.isBlank() || endpointSlug.isBlank()) return List.of(); + return List.of("acp_" + endpointSlug + "_" + slug + "_prompt"); + } + + private static String sanitize(String raw) { + if (raw == null) return ""; + return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_]", "_"); + } + + private static String errorJson(String msg) { + return JSONUtil.createObj().set("error", msg).toString(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java index aee6d099..ece5db00 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java @@ -86,6 +86,11 @@ public class SkillManifest { private KnowledgeBinding knowledge; + // ==================== Phase 7b — type=acp binding ==================== + + /** Set when {@code type=acp}. Resolves to a {@code mate_acp_endpoint} row. */ + private AcpBinding acp; + // ==================== Forward-compat catch-all ==================== /** Unknown frontmatter keys are stashed here so a future field @@ -169,6 +174,30 @@ public class SkillManifest { } } + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public static class AcpBinding { + /** ACP endpoint slug (matches {@code mate_acp_endpoint.name}). */ + private String endpoint; + /** + * Optional system-prompt override delivered to the upstream agent + * before the user's message. Useful when a single ACP CLI is + * shared across several MateClaw skills with different personas. + */ + private String systemPrefix; + /** + * Working directory hint for the spawned ACP process; defaults + * to the current MateClaw workspace path when null. + */ + private String cwd; + /** + * Resolved endpoint id, written at install time. Null when the + * endpoint slug couldn't be resolved. + */ + private Long resolvedEndpointId; + } + @Data @Builder @JsonInclude(JsonInclude.Include.NON_EMPTY) diff --git a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java index b6539cf0..4227aa73 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java @@ -39,6 +39,7 @@ public class SkillManifestParser { "dependencies", "dashboard", "self-evolution", "self_evolution", "knowledge", + "acp", // legacy / housekeeping fields that aren't manifest-relevant "metadata" ); @@ -88,6 +89,7 @@ public class SkillManifestParser { .dashboardMetrics(parseDashboard(fm.get("dashboard"))) .selfEvolution(parseSelfEvolution(coalesce(fm, "self-evolution", "self_evolution"))) .knowledge(parseKnowledge(fm.get("knowledge"))) + .acp(parseAcp(fm.get("acp"))) .extras(extractUnknown(fm)); return b.build(); @@ -247,6 +249,26 @@ public class SkillManifestParser { .build(); } + @SuppressWarnings("unchecked") + private SkillManifest.AcpBinding parseAcp(Object raw) { + if (!(raw instanceof Map map)) return null; + Map m = (Map) map; + Long resolvedId = null; + Object idVal = m.get("resolvedEndpointId"); + if (idVal == null) idVal = m.get("resolved_endpoint_id"); + if (idVal instanceof Number n) resolvedId = n.longValue(); + else if (idVal instanceof String s && !s.isBlank()) { + try { resolvedId = Long.parseLong(s.trim()); } catch (NumberFormatException ignored) { /* leave null */ } + } + return SkillManifest.AcpBinding.builder() + .endpoint(string(m, "endpoint")) + .systemPrefix(stringOrDefault(m, "system_prefix", + stringOrDefault(m, "systemPrefix", null))) + .cwd(string(m, "cwd")) + .resolvedEndpointId(resolvedId) + .build(); + } + // ==================== helpers ==================== private Map extractUnknown(Map fm) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java index 2c628298..502e20ce 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java @@ -7,6 +7,7 @@ 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.AcpSkillWrapperToolFactory; import vip.mate.skill.knowledge.WikiSkillWrapperToolFactory; import vip.mate.skill.manifest.SkillManifest; import vip.mate.skill.manifest.SkillManifestParser; @@ -59,6 +60,13 @@ public class SkillPackageResolver { * services that lag this bean's construction order. */ private final WikiSkillWrapperToolFactory wikiWrapperFactory; + /** + * RFC-090 Phase 7b — type=acp skill wrapper factory. + * Same {@code @Lazy} treatment because it pulls in + * AcpEndpointService → mybatis mapper which boots later in the + * Spring lifecycle. + */ + private final AcpSkillWrapperToolFactory acpWrapperFactory; /** * {@code @Lazy} on ToolRegistry — same lazy-resolution loop as * {@code SkillDependencyChecker}; without this we'd reach for the @@ -84,6 +92,7 @@ public class SkillPackageResolver { SkillWorkspaceManager workspaceManager, SkillMapper skillMapper, @Lazy WikiSkillWrapperToolFactory wikiWrapperFactory, + @Lazy AcpSkillWrapperToolFactory acpWrapperFactory, @Lazy ToolRegistry toolRegistry) { this.frontmatterParser = frontmatterParser; this.manifestParser = manifestParser; @@ -94,6 +103,7 @@ public class SkillPackageResolver { this.workspaceManager = workspaceManager; this.skillMapper = skillMapper; this.wikiWrapperFactory = wikiWrapperFactory; + this.acpWrapperFactory = acpWrapperFactory; this.toolRegistry = toolRegistry; } @@ -445,7 +455,7 @@ public class SkillPackageResolver { // No frontmatter at all: leave manifest null and let // legacy callers continue using dependencyReady. Also // make sure no wrapper tools linger from a prior shape. - deregisterKnowledgeWrappers(resolved.getId()); + deregisterSkillWrappers(resolved.getId()); return; } resolved.setManifest(manifest); @@ -457,6 +467,12 @@ public class SkillPackageResolver { // getEffectiveAllowedTools() runs. applyKnowledgeWrappers(resolved, manifest); + // RFC-090 Phase 7b — type=acp gets its own wrapper that + // delegates to the configured ACP endpoint. Parallel + // structure to knowledge wrappers; tracked under the same + // registeredWrappers map so deregistration covers both. + applyAcpWrappers(resolved, manifest); + // Build requirement lookup for feature checks. Map reqByKey = new LinkedHashMap<>(); for (SkillManifest.RequirementDef r : manifest.getRequires()) { @@ -525,7 +541,7 @@ public class SkillPackageResolver { && manifest.getKnowledge().getBindKb() != null && !manifest.getKnowledge().getBindKb().isBlank(); if (!isKnowledge || !resolved.isEnabled()) { - deregisterKnowledgeWrappers(resolved.getId()); + deregisterSkillWrappers(resolved.getId()); return; } @@ -535,7 +551,7 @@ public class SkillPackageResolver { 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()); + deregisterSkillWrappers(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. @@ -549,7 +565,7 @@ public class SkillPackageResolver { // 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()); + deregisterSkillWrappers(resolved.getId()); java.util.List wrappers = wikiWrapperFactory.buildWrappers(manifest, kbId); if (wrappers.isEmpty()) { @@ -582,7 +598,7 @@ public class SkillPackageResolver { manifest.setAllowedTools(mergedAllowed); } - private void deregisterKnowledgeWrappers(Long skillId) { + private void deregisterSkillWrappers(Long skillId) { if (skillId == null) return; java.util.Set previous = registeredWrappers.remove(skillId); if (previous == null || previous.isEmpty()) return; @@ -595,6 +611,77 @@ public class SkillPackageResolver { } } + /** + * RFC-090 Phase 7b — register / refresh / deregister wrapper tools + * for a single ACP skill. Mirrors {@link #applyKnowledgeWrappers}. + * + *

Endpoint resolution semantics match the knowledge path: + *

+ */ + private void applyAcpWrappers(ResolvedSkill resolved, SkillManifest manifest) { + boolean isAcp = "acp".equalsIgnoreCase(manifest.getType()) + && manifest.getAcp() != null + && manifest.getAcp().getEndpoint() != null + && !manifest.getAcp().getEndpoint().isBlank(); + if (!isAcp || !resolved.isEnabled()) { + // applyKnowledgeWrappers already cleared registrations for + // type=knowledge; we don't double-clear when this branch + // also runs for the same skill — type is single-valued so + // only one branch ever holds wrappers at a time. + return; + } + + Long endpointId = manifest.getAcp().getResolvedEndpointId(); + if (endpointId == null) { + endpointId = acpWrapperFactory.resolveEndpointId(manifest.getAcp().getEndpoint()); + if (endpointId == null) { + log.warn("Skill '{}' type=acp but endpoint '{}' did not resolve", + resolved.getName(), manifest.getAcp().getEndpoint()); + deregisterSkillWrappers(resolved.getId()); + resolved.setMissingDependencies(java.util.List.of( + "acp:" + manifest.getAcp().getEndpoint())); + return; + } + manifest.getAcp().setResolvedEndpointId(endpointId); + } + + // Fresh build: drop any prior knowledge-wrapper registration + // (impossible in practice since type is single-valued, but + // makes the lifecycle safe across edits where the user flips + // type from knowledge to acp). + deregisterSkillWrappers(resolved.getId()); + + java.util.List wrappers = acpWrapperFactory.buildWrappers(manifest); + if (wrappers.isEmpty()) return; + java.util.Set registered = new java.util.LinkedHashSet<>(); + Long entityId = resolved.getId(); + for (ToolCallback cb : wrappers) { + String name = cb.getToolDefinition().name(); + registered.add(name); + toolRegistry.registerPluginTool(cb, () -> + entityId != null && resolved.isEnabled()); + } + if (entityId != null) { + registeredWrappers.put(entityId, registered); + } + + // Append wrapper names to allowedTools so getEffectiveAllowedTools + // surfaces them like any other manifest-declared tool. + java.util.List mergedAllowed = new java.util.ArrayList<>( + manifest.getAllowedTools() == null ? java.util.List.of() : manifest.getAllowedTools()); + for (String wrapperName : acpWrapperFactory.wrapperNames(manifest)) { + if (!mergedAllowed.contains(wrapperName)) mergedAllowed.add(wrapperName); + } + manifest.setAllowedTools(mergedAllowed); + } + // ==================== 阶段 4:综合判定 ==================== private void resolveRuntimeAvailability(ResolvedSkill resolved) { diff --git a/mateclaw-server/src/main/resources/skill-templates/codex-coding-helper/template.json b/mateclaw-server/src/main/resources/skill-templates/codex-coding-helper/template.json new file mode 100644 index 00000000..989bc4a7 --- /dev/null +++ b/mateclaw-server/src/main/resources/skill-templates/codex-coding-helper/template.json @@ -0,0 +1,43 @@ +{ + "id": "codex-coding-helper", + "name": "Codex Coding Helper", + "nameZh": "Codex 编程助手", + "category": "system", + "type": "acp", + "icon": "🤖", + "description": "Delegate coding tasks to OpenAI Codex CLI via ACP. Requires the codex endpoint to be enabled in Settings ▸ ACP Endpoints.", + "descriptionZh": "通过 ACP 协议把编码任务委派给 OpenAI Codex CLI。需先在 Settings ▸ ACP Endpoints 启用 codex 端点。", + "fields": [ + { + "key": "skill_name", + "label": "Skill 标识 (slug)", + "type": "text", + "required": true, + "placeholder": "team-codex-helper", + "hint": "小写英文 + 连字符;最终生成的工具名为 acp_codex__prompt" + }, + { + "key": "display_name_zh", + "label": "显示名 (中文)", + "type": "text", + "required": false, + "placeholder": "团队 Codex 助手" + }, + { + "key": "system_prefix", + "label": "上游 system 前缀", + "type": "textarea", + "required": false, + "default": "你正在为一个 Java + Vue 3 项目工作,遵循团队的 monorepo 习惯。", + "hint": "每次发送给 Codex 之前会自动拼到用户输入前面" + }, + { + "key": "cwd_hint", + "label": "工作目录提示", + "type": "text", + "required": false, + "placeholder": "/path/to/project (留空使用当前 MateClaw workspace)" + } + ], + "skillMd": "---\nname: {{skill_name}}\ndescription: {{display_name_zh}} - 通过 ACP 委派给 Codex\ntype: acp\nicon: \"🤖\"\ncategory: system\nversion: 1.0.0\nauthor: skill-template-wizard\nacp:\n endpoint: codex\n system_prefix: |\n {{system_prefix}}\n cwd: {{cwd_hint}}\nself-evolution:\n lessons_enabled: true\n lessons_max_entries: 30\n---\n\n# {{display_name_zh}}\n\n这是一个 ACP 委派 skill。Agent 会把编码任务转发给 OpenAI Codex CLI。\n\n## 使用前提\n\n1. 在 Settings ▸ ACP Endpoints 启用 codex 端点 (默认 disabled)\n2. 确认本机已安装 npx + Codex CLI\n3. 该 skill 暴露一个工具 `acp_codex_{{skill_name}}_prompt`,参数为 `prompt: string`\n\n## 委派粒度建议\n\n- 单个 PR 的范围:把整个 Diff + 期望写一段长 prompt 给 Codex\n- 单个 file 修改:包含 file path + 上下文片段\n- 探索性分析:先让 Codex 读 / 列目录,再决定改什么\n" +}