mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(skill): type=acp delegates to ACP endpoint
This commit is contained in:
parent
b8ce36f6cd
commit
0b6d9faaf3
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user