feat(skill): type=acp delegates to ACP endpoint

This commit is contained in:
matevip 2026-05-01 09:50:18 +08:00
parent b8ce36f6cd
commit 0b6d9faaf3
7 changed files with 636 additions and 26 deletions

View File

@ -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<Long, CompletableFuture<JsonNode>> 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.
*
* <p>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<JsonNode> 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<JsonNode, JsonNode> 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<JsonNode> 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<JsonNode> 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<JsonNode, JsonNode> handler) {
this.requestHandler = handler != null ? handler : msg -> null;
}
@Override
public void close() {
closed = true;

View File

@ -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.
*
* <p>One {@link #prompt(String, String, String)} call:
* <ol>
* <li>Looks up the endpoint row, refuses if disabled or undefined.</li>
* <li>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).</li>
* <li>Runs {@code initialize session/new session/prompt}.</li>
* <li>Accumulates {@code agent_message_chunk} text from
* {@code session/update} notifications into the response.</li>
* <li>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.</li>
* <li>Returns the accumulated text or a JSON error blob on failure.</li>
* </ol>
*
* <p>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<String>}.
*/
@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<String> args = endpointService.parseArgs(endpoint);
Map<String, String> 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;
}
}

View File

@ -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.
*
* <p>For every {@code type=acp} skill, registers exactly one tool:
* {@code acp_<endpoint>_<skill>_prompt(prompt)}. The wrapper closes
* over the resolved endpoint id so the LLM never has to (and can't
* accidentally) target a different endpoint.
*
* <p>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<ToolCallback> 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<String> 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();
}
}

View File

@ -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)

View File

@ -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<String, Object> m = (Map<String, Object>) 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<String, Object> extractUnknown(Map<String, Object> fm) {

View File

@ -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<String, SkillManifest.RequirementDef> 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<ToolCallback> 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<String> 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}.
*
* <p>Endpoint resolution semantics match the knowledge path:
* <ul>
* <li>{@code type != acp} or skill disabled deregister.</li>
* <li>{@code endpoint} unresolvable deregister + add a missing-
* dependency hint so the UI shows SETUP_NEEDED.</li>
* <li>Otherwise: register one wrapper, append name to
* {@code manifest.allowedTools} so the LLM advertisement
* picks it up.</li>
* </ul>
*/
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<ToolCallback> wrappers = acpWrapperFactory.buildWrappers(manifest);
if (wrappers.isEmpty()) return;
java.util.Set<String> 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<String> 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) {

View File

@ -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_<slug>_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"
}