From 5e6764ecf1be24aadec03f3d8c2968a7f446fac8 Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 1 May 2026 12:24:25 +0800 Subject: [PATCH] fix(skill): tool-gate whitelist + markdown link host normalization When an agent had any skill bound, the runtime tool gate was silently hiding @Tool beans that aren't declared in any skill manifest, even though the global system prompts (SOUL.md / "Web Search Capability" / "File Reading Guidelines") explicitly tell the LLM these tools are available. Result: the model would call search / renderDocx / read_file / etc., hit "Tool not found", then either give up or fall back to unhelpful behaviour (e.g. dumping markdown text instead of producing a .docx download). This commit: - Adds universally-promised, agent-wide tools to SYSTEM_LEVEL_TOOLS so they bypass the manifest restriction: document/media generation (renderDocx*, image_generate, music_generate, video_generate), global capability tools the system prompt mentions (search, browser_use, read_file / write_file / edit_file / execute_shell_command, detect_file_type, extract_*_text, readMateClawDoc), skill discovery siblings (listSkillFiles, listAvailableSkills), and the delegate triplet (delegateToAgent, delegateParallel, listAvailableAgents). - Fixes 5 entries in the prior whitelist whose names did not match any real @Tool bean and were therefore silently dead: read_workspace_file -> read_workspace_memory_file write_workspace_file -> write_workspace_memory_file list_workspace_files -> list_workspace_memory_files delegate_agent -> delegateToAgent datetime -> getCurrentDate / getCurrentDateTime / getCurrentTime Also adds the missing edit_workspace_memory_file. - In the chat markdown renderer, strips any hallucinated https?:// prefix from /api/v1/files/generated/ download links before building the . Multiple LLMs have been observed prepending bogus hosts when echoing tool-returned download URLs back to the user, breaking the click. One-line defensive normalization independent of which model is in use. Verified end-to-end on a previously-broken agent: search / browser_use / execute_shell_command / renderDocx all dispatch correctly now and the final markdown link is a clean same-origin path. 36 whitelist entries cross-checked against real @Tool method names. AgentBindingServiceTest green. --- .../vip/mate/acp/client/AcpStdioClient.java | 14 +- .../acp/event/AcpEndpointChangedEvent.java | 27 + .../mate/acp/service/AcpConnectionTester.java | 17 +- .../acp/service/AcpDelegationService.java | 18 +- .../mate/acp/service/AcpEndpointService.java | 30 ++ .../mate/acp/service/AcpRuntimeSupport.java | 188 +++++++ .../binding/service/AgentBindingService.java | 69 ++- .../vip/mate/skill/acp/AcpSkillBridge.java | 495 ++++++++++++++++++ .../skill/controller/SkillController.java | 38 ++ .../skill/runtime/SkillRuntimeService.java | 23 +- .../src/composables/useMarkdownRenderer.ts | 13 +- mateclaw-ui/src/i18n/locales/en-US.ts | 22 +- mateclaw-ui/src/i18n/locales/zh-CN.ts | 46 +- mateclaw-ui/src/views/AcpEndpoints.vue | 296 ++++++++++- mateclaw-ui/src/views/ChatConsole.vue | 4 +- mateclaw-ui/src/views/Memory/index.vue | 2 +- mateclaw-ui/src/views/SkillMarket.vue | 17 +- 17 files changed, 1275 insertions(+), 44 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/acp/event/AcpEndpointChangedEvent.java create mode 100644 mateclaw-server/src/main/java/vip/mate/acp/service/AcpRuntimeSupport.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java 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 788b7b49..f75ce8be 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 @@ -166,11 +166,23 @@ public class AcpStdioClient implements AutoCloseable { * Send {@code session/new} — establishes a session for prompting. * For the connection-test path we don't actually prompt, just * verify the server accepts the handshake. + * + *

The {@code cwd} parameter is always written into the request + * body. Zed's ACP Zod schema (used by {@code @zed-industries/claude- + * agent-acp} and the codex variant) marks {@code cwd} as a required + * string and returns {@code -32602 Invalid params} when it's + * missing. If the caller passes null/blank we substitute the JVM + * working directory — a workspace-aware default lives in + * {@code AcpRuntimeSupport#resolveCwd}, but this fallback ensures + * the protocol never sees {@code undefined} regardless of caller. */ public JsonNode newSession(String cwd, long timeoutMillis) throws IOException, InterruptedException { ObjectNode params = mapper.createObjectNode(); - if (cwd != null && !cwd.isBlank()) params.put("cwd", cwd); + String safeCwd = (cwd == null || cwd.isBlank()) + ? System.getProperty("user.dir", ".") + : cwd; + params.put("cwd", safeCwd); params.set("mcpServers", mapper.createArrayNode()); return sendRequest("session/new", params, timeoutMillis); } diff --git a/mateclaw-server/src/main/java/vip/mate/acp/event/AcpEndpointChangedEvent.java b/mateclaw-server/src/main/java/vip/mate/acp/event/AcpEndpointChangedEvent.java new file mode 100644 index 00000000..d7b380b6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/acp/event/AcpEndpointChangedEvent.java @@ -0,0 +1,27 @@ +package vip.mate.acp.event; + +/** + * Lifecycle event for ACP endpoint rows. + * + *

Published by {@code AcpEndpointService} whenever a row is created, + * updated, toggled, or deleted. Listened to by + * {@code AcpSkillBridge} so it can re-sync the auto-bridged virtual + * skill cards and their wrapper tool registrations without a full + * application restart. + * + *

Mirrors the {@code SkillWorkspaceEvent} pattern — a small immutable + * record carrying just enough context for listeners to fan out. + */ +public record AcpEndpointChangedEvent(Long endpointId, String name, Type type) { + + public enum Type { + /** Row inserted. */ + CREATED, + /** Row attributes updated (command/args/env/etc.). */ + UPDATED, + /** {@code enabled} flag flipped. */ + TOGGLED, + /** Row deleted. */ + DELETED + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/acp/service/AcpConnectionTester.java b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpConnectionTester.java index 656b3179..3393b96d 100644 --- a/mateclaw-server/src/main/java/vip/mate/acp/service/AcpConnectionTester.java +++ b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpConnectionTester.java @@ -31,6 +31,7 @@ public class AcpConnectionTester { private final ObjectMapper objectMapper; private final AcpEndpointService endpointService; + private final AcpRuntimeSupport runtimeSupport; /** * Spawn the configured agent, exchange initialize + session/new, @@ -46,11 +47,16 @@ public class AcpConnectionTester { List args = endpointService.parseArgs(endpoint); Map env = endpointService.parseEnv(endpoint); result.put("args", args); + // Same as AcpDelegationService — Zed's ACP server requires a + // non-blank cwd at session/new, so the connection test must + // also default it. The "Test" button used to fail at session/new + // with -32602 even when the CLI itself was healthy. + String resolvedCwd = runtimeSupport.resolveCwd(endpoint, null); AcpStdioClient client; try { client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(), - args, env, /* cwd */ null); + args, env, resolvedCwd); } catch (Exception e) { return persistAndReturn(endpoint, result, "ERROR", "Spawn failed: " + e.getMessage(), started); @@ -85,15 +91,18 @@ public class AcpConnectionTester { // session/new validates that the agent really stands up a // working session, not just initialize handshake. try { - JsonNode sessionResp = autoClose.newSession(null, SESSION_NEW_TIMEOUT_MS); + JsonNode sessionResp = autoClose.newSession(resolvedCwd, SESSION_NEW_TIMEOUT_MS); if (sessionResp != null && sessionResp.has("sessionId")) { result.put("sessionId", sessionResp.path("sessionId").asText("")); } } catch (Exception e) { // session/new may fail for legitimate reasons (e.g. agent // requires auth flow first). Still report OK on initialize - // but flag in the message. - result.put("sessionWarning", e.getMessage()); + // but flag in the message — translated when it smells + // like an auth error so the test page UI shows actionable + // text instead of raw JSON-RPC. + String authHint = runtimeSupport.translateAuthError(endpoint, e.getMessage()); + result.put("sessionWarning", authHint != null ? authHint : e.getMessage()); } } catch (Exception e) { return persistAndReturn(endpoint, result, "ERROR", 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 index 02d591e0..78d1c13e 100644 --- a/mateclaw-server/src/main/java/vip/mate/acp/service/AcpDelegationService.java +++ b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpDelegationService.java @@ -57,6 +57,7 @@ public class AcpDelegationService { private final ObjectMapper objectMapper; private final AcpEndpointService endpointService; + private final AcpRuntimeSupport runtimeSupport; /** * Run a one-shot ACP prompt against {@code endpointName}. Returns @@ -88,12 +89,16 @@ public class AcpDelegationService { List args = endpointService.parseArgs(endpoint); Map env = endpointService.parseEnv(endpoint); boolean trusted = !Boolean.FALSE.equals(endpoint.getTrusted()); + // Always resolve cwd to a real directory: Zed's ACP Zod schema + // marks cwd as a required string and rejects {@code undefined} + // with -32602. See {@link AcpRuntimeSupport#resolveCwd}. + String resolvedCwd = runtimeSupport.resolveCwd(endpoint, cwdHint); StringBuilder accumulator = new StringBuilder(); AcpStdioClient client; try { client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(), - args, env, cwdHint); + args, env, resolvedCwd); } catch (IOException e) { throw new MateClawException("err.acp.spawn_failed", "Failed to spawn ACP agent '" + endpointName + "': " + e.getMessage()); @@ -109,7 +114,7 @@ public class AcpDelegationService { "ACP protocol mismatch with endpoint '" + endpointName + "'"); } - JsonNode session = autoClose.newSession(cwdHint, SESSION_NEW_TIMEOUT_MS); + JsonNode session = autoClose.newSession(resolvedCwd, 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", @@ -123,6 +128,15 @@ public class AcpDelegationService { } catch (IOException | InterruptedException e) { if (e instanceof InterruptedException) Thread.currentThread().interrupt(); log.warn("ACP delegation failed for endpoint '{}': {}", endpointName, e.getMessage()); + // Upstream CLIs (claude-code / codex / qwen-code) wrap their + // own auth failures in opaque JSON-RPC noise. Recognise the + // 401/403/forbidden/unauthorized fingerprints and rewrite + // the message into something the user can act on, with the + // exact env var name they need to set. + String authHint = runtimeSupport.translateAuthError(endpoint, e.getMessage()); + if (authHint != null) { + throw new MateClawException("err.acp.auth_failed", authHint); + } throw new MateClawException("err.acp.delegation_failed", "ACP delegation to '" + endpointName + "' failed: " + e.getMessage()); } diff --git a/mateclaw-server/src/main/java/vip/mate/acp/service/AcpEndpointService.java b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpEndpointService.java index 12d34411..fd2e7f60 100644 --- a/mateclaw-server/src/main/java/vip/mate/acp/service/AcpEndpointService.java +++ b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpEndpointService.java @@ -5,7 +5,9 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import vip.mate.acp.event.AcpEndpointChangedEvent; import vip.mate.acp.model.AcpEndpointEntity; import vip.mate.acp.repository.AcpEndpointMapper; import vip.mate.exception.MateClawException; @@ -36,6 +38,7 @@ public class AcpEndpointService { private final AcpEndpointMapper mapper; private final ObjectMapper objectMapper; + private final ApplicationEventPublisher eventPublisher; public List list() { return mapper.selectList(new LambdaQueryWrapper() @@ -43,6 +46,17 @@ public class AcpEndpointService { .orderByAsc(AcpEndpointEntity::getName)); } + /** + * Subset of {@link #list()} that returns only enabled rows. + * Used by {@code AcpSkillBridge} to enumerate virtual skill cards + * (one per enabled endpoint). + */ + public List listEnabled() { + return mapper.selectList(new LambdaQueryWrapper() + .eq(AcpEndpointEntity::getEnabled, true) + .orderByAsc(AcpEndpointEntity::getName)); + } + public AcpEndpointEntity get(Long id) { AcpEndpointEntity ep = mapper.selectById(id); if (ep == null) throw new MateClawException("err.acp.endpoint_not_found", @@ -80,6 +94,7 @@ public class AcpEndpointService { if (input.getWorkspaceId() == null) input.setWorkspaceId(1L); mapper.insert(input); log.info("Created ACP endpoint: {}", input.getName()); + publish(input, AcpEndpointChangedEvent.Type.CREATED); return input; } @@ -104,6 +119,7 @@ public class AcpEndpointService { existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes()); } mapper.updateById(existing); + publish(existing, AcpEndpointChangedEvent.Type.UPDATED); return existing; } @@ -115,15 +131,29 @@ public class AcpEndpointService { } mapper.deleteById(id); log.info("Deleted ACP endpoint: {}", existing.getName()); + publish(existing, AcpEndpointChangedEvent.Type.DELETED); } public AcpEndpointEntity toggle(Long id, boolean enabled) { AcpEndpointEntity existing = get(id); existing.setEnabled(enabled); mapper.updateById(existing); + publish(existing, AcpEndpointChangedEvent.Type.TOGGLED); return existing; } + private void publish(AcpEndpointEntity ep, AcpEndpointChangedEvent.Type type) { + try { + eventPublisher.publishEvent(new AcpEndpointChangedEvent( + ep.getId(), ep.getName(), type)); + } catch (Exception e) { + // Listener failures must not break the CRUD path. The bridge + // will resync on the next ApplicationReady tick anyway. + log.warn("Failed to publish AcpEndpointChangedEvent for '{}': {}", + ep.getName(), e.getMessage()); + } + } + /** Persist a connection-test outcome on the row. */ public void recordTestResult(Long id, String status, String error) { AcpEndpointEntity existing = mapper.selectById(id); diff --git a/mateclaw-server/src/main/java/vip/mate/acp/service/AcpRuntimeSupport.java b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpRuntimeSupport.java new file mode 100644 index 00000000..53a237c6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpRuntimeSupport.java @@ -0,0 +1,188 @@ +package vip.mate.acp.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.acp.model.AcpEndpointEntity; +import vip.mate.workspace.core.model.WorkspaceEntity; +import vip.mate.workspace.core.service.WorkspaceService; + +import java.io.File; +import java.util.Locale; + +/** + * Shared runtime helpers for ACP code paths. + * + *

Two responsibilities, both motivated by upstream ACP servers + * (e.g. {@code @zed-industries/claude-agent-acp}) being strict about + * inputs and noisy in failure modes: + * + *

    + *
  • {@link #resolveCwd} — pick a non-blank cwd for {@code session/new}. + * Zed's ACP Zod schema marks {@code cwd} as a required string and + * returns {@code -32602 Invalid params} when it's missing. We + * prefer the endpoint's bound workspace {@code base_path} (per- + * workspace context) and fall back to the JVM working directory + * only as a last resort. Never returns null/blank.
  • + * + *
  • {@link #translateAuthError} — turn upstream JSON-RPC noise like + * {@code "API Error: 403 {...forbidden...}"} into an actionable + * hint that names the env var the user actually has to set in + * Settings ▸ ACP Endpoints (e.g. {@code ANTHROPIC_API_KEY} for + * claude-code, {@code OPENAI_API_KEY} for codex). Returns + * {@code null} when the error doesn't smell like an auth failure.
  • + *
+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AcpRuntimeSupport { + + private final WorkspaceService workspaceService; + + /** + * Resolution order (first non-blank wins): + *
    + *
  1. Caller-provided hint (skill manifest's {@code acp.cwd}, + * wrapper tool {@code cwd} arg, or explicit override).
  2. + *
  3. Workspace {@code base_path} when the endpoint is bound to a + * workspace and the workspace declares one.
  4. + *
  5. {@code System.getProperty("user.dir")} — the JVM working + * directory at server launch. Reasonable for a single-user + * desktop install, but exposes the server's launch dir to the + * upstream agent, which is why it's last.
  6. + *
+ */ + public String resolveCwd(AcpEndpointEntity endpoint, String callerHint) { + if (callerHint != null && !callerHint.isBlank()) { + return callerHint; + } + if (endpoint != null && endpoint.getWorkspaceId() != null) { + try { + WorkspaceEntity ws = workspaceService.getById(endpoint.getWorkspaceId()); + if (ws != null && ws.getBasePath() != null && !ws.getBasePath().isBlank()) { + File f = new File(ws.getBasePath()); + if (f.isDirectory()) return f.getAbsolutePath(); + } + } catch (Exception e) { + log.debug("Workspace lookup failed for ACP cwd default (id={}): {}", + endpoint.getWorkspaceId(), e.getMessage()); + } + } + return System.getProperty("user.dir", "."); + } + + /** + * Detect upstream auth errors and emit an actionable hint string. + * Returns null when the message doesn't match — caller should keep + * the original error as-is. + * + *

Heuristic: looks for HTTP-like 401/403 markers OR the words + * {@code forbidden / unauthorized / not allowed / api key / token} + * in the original message (case-insensitive). The patterns are loose + * on purpose — different ACP CLIs phrase auth errors differently + * and the cost of a false positive (a slightly more verbose error + * banner) is much smaller than a false negative (user staring at a + * raw JSON-RPC blob). + * + *

Special case: a claude-code endpoint returning {@code 403 + * "Request not allowed"} is almost always the keychain-hijack + * scenario rather than a wrong API key. The third-party + * {@code @zed-industries/claude-agent-acp} package wraps + * {@code @anthropic-ai/claude-agent-sdk}, whose auth dispatcher + * checks the macOS keychain ({@code Claude Code-credentials}) / + * {@code ~/.claude/credentials.json} BEFORE the + * {@code ANTHROPIC_API_KEY} env var. So a host that's done + * {@code claude login} silently shadows whatever API key the user + * configured in the endpoint env, and Anthropic's API rejects the + * subscription OAuth token (first-party-only) with the very + * specific {@code "Request not allowed"} error string. We detect + * that exact combination and surface the keychain-clearing remedy + * instead of the generic "set ANTHROPIC_API_KEY" hint, which + * doesn't apply here. + */ + public String translateAuthError(AcpEndpointEntity endpoint, String originalMessage) { + if (originalMessage == null) return null; + String lower = originalMessage.toLowerCase(Locale.ROOT); + boolean looksLikeAuth = + lower.contains("403") + || lower.contains("401") + || lower.contains("forbidden") + || lower.contains("unauthorized") + || lower.contains("not allowed") + || lower.contains("invalid api key") + || lower.contains("invalid token") + || lower.contains("authenticate"); + if (!looksLikeAuth) return null; + + String name = endpoint != null && endpoint.getName() != null ? endpoint.getName() : "(unknown)"; + String slug = lower(name); + String command = endpoint != null ? lower(endpoint.getCommand()) : ""; + + // Keychain-hijack detection — must come before the generic env- + // missing branch because both would superficially match. + boolean keychainHijack = lower.contains("request not allowed") + && (slug.contains("claude") || command.contains("claude-agent-acp")); + if (keychainHijack) { + StringBuilder sb = new StringBuilder(); + sb.append("ACP endpoint '").append(name).append("' upstream auth failed with "); + sb.append("'Request not allowed' — almost always means the host CLI's OAuth "); + sb.append("credentials are hijacking the SDK auth path. "); + sb.append("The Claude Agent SDK reads ~/.claude/ / macOS keychain BEFORE the "); + sb.append("ANTHROPIC_API_KEY env var, so the API key you configured here is "); + sb.append("never sent — Anthropic rejects the subscription OAuth token because "); + sb.append("third-party processes aren't allowed to use it. "); + sb.append("To fix: "); + sb.append("(macOS) run `claude logout`, or `security delete-generic-password "); + sb.append("-s \"Claude Code-credentials\"`; "); + sb.append("(Linux / Windows) delete ~/.claude/credentials.json. "); + sb.append("Then click Test connection again. Original: ").append(originalMessage); + return sb.toString(); + } + + String envVar = expectedAuthEnvVar(endpoint); + StringBuilder sb = new StringBuilder(); + sb.append("ACP endpoint '").append(name).append("' upstream auth failed. "); + sb.append("Most likely the endpoint env has no API key. "); + sb.append("Edit Settings ▸ ACP Endpoints → ").append(name).append(" → env, "); + if (envVar != null) { + sb.append("add `{\"").append(envVar).append("\":\"...\"}`"); + } else { + sb.append("add the appropriate API key for this CLI"); + } + sb.append(". Note: claude-code / codex / qwen-code refuse OAuth tokens from their host CLIs, "); + sb.append("so a real API key is required. Original: ").append(originalMessage); + return sb.toString(); + } + + /** + * Best-effort guess of the API key env var the upstream CLI expects. + * Returns null when we don't recognise the endpoint — caller emits a + * generic "appropriate API key" hint instead. + */ + public String expectedAuthEnvVar(AcpEndpointEntity endpoint) { + if (endpoint == null) return null; + String name = lower(endpoint.getName()); + String command = lower(endpoint.getCommand()); + // Match by name first (slug is the stable identifier); fall back + // to command keywords for user-defined rows. + if (name.contains("claude") || command.contains("claude-agent-acp") || command.contains("anthropic")) { + return "ANTHROPIC_API_KEY"; + } + if (name.contains("codex") || command.contains("codex") || command.contains("openai")) { + return "OPENAI_API_KEY"; + } + if (name.contains("qwen") || command.contains("qwen") || command.contains("dashscope")) { + return "DASHSCOPE_API_KEY"; + } + if (name.contains("gemini") || command.contains("gemini") || command.contains("google-genai")) { + return "GOOGLE_API_KEY"; + } + // opencode multi-model — no single canonical env var. + return null; + } + + private static String lower(String s) { + return s == null ? "" : s.toLowerCase(Locale.ROOT); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index c9498e21..9ecf1166 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -228,25 +228,74 @@ public class AgentBindingService { * {@link #getEffectiveToolNames} allowlist completely. */ private static final Set SYSTEM_LEVEL_TOOLS = Set.of( - // Memory write/read primitives — every agent needs these - // regardless of skill bindings, otherwise the self-evolution - // path collapses (§11.3 / §11.4). + // Structured memory primitives — used by every agent regardless + // of skill bindings, otherwise the self-evolution path collapses + // (§11.3 / §11.4). "record_lesson", "remember", "remember_structured", "recall_structured", "forget_structured", - // Workspace memory file CRUD (PROFILE.md / MEMORY.md / SOUL.md) - "read_workspace_file", - "write_workspace_file", - "list_workspace_files", + // Workspace memory file CRUD (PROFILE.md / MEMORY.md / SOUL.md / + // memory/YYYY-MM-DD.md). Prior versions whitelisted + // "read_workspace_file" / "write_workspace_file" / + // "list_workspace_files" — those names match no @Tool bean; the + // actual function names carry the "_memory" segment, so the + // earlier carve-out was silently dead. + "list_workspace_memory_files", + "read_workspace_memory_file", + "write_workspace_memory_file", + "edit_workspace_memory_file", // Skill discovery / dispatch — skills are docs, not callables; // these helpers let the LLM read SKILL.md / run scripts. "readSkillFile", "runSkillScript", - // Date/time + delegate — fundamental cross-skill utilities - "datetime", - "delegate_agent" + "listSkillFiles", + "listAvailableSkills", + // Date / time — prior whitelist had a fictional "datetime"; the + // real DateTimeTool exposes three separate methods. + "getCurrentDate", + "getCurrentDateTime", + "getCurrentTime", + // Multi-agent delegation — prior whitelist had "delegate_agent", + // but DelegateAgentTool's @Tool methods are delegateToAgent / + // delegateParallel / listAvailableAgents. Same dead-name bug. + "delegateToAgent", + "delegateParallel", + "listAvailableAgents", + // Document / media generation — agent-wide capabilities, never + // declared inside any skill manifest. Pre-Phase-2b these were + // universally visible; the new gate silently strips them whenever + // any skill is bound, breaking "generate a Word doc / image / + // song / video" intents on agents that happen to have a skill + // on. Regression observed 2026-05-01: a Code Reviewer agent with + // skills bound dropped renderDocx and fell back to dumping the + // markdown body for the user to copy. + "renderDocx", + "renderDocxFromFile", + "renderDocxFromFiles", + "image_generate", + "music_generate", + "video_generate", + // Universal capabilities the global system prompts (SOUL.md / + // AGENTS.md / "Web Search Capability" / "File Reading Guidelines") + // explicitly tell the LLM exist. Pre-Phase-2b they were globally + // available; the new gate silently hid them on any agent with + // skills bound, so the prompt promises a tool the registry then + // refuses ("Tool not found: search"). Observed 2026-05-01 on the + // Code Reviewer agent — the model called search → got + // not-found → gave up before ever reaching renderDocx. + "search", + "browser_use", + "read_file", + "write_file", + "edit_file", + "execute_shell_command", + "detect_file_type", + "extract_document_text", + "extract_pdf_text", + "extract_docx_text", + "readMateClawDoc" ); private ResolvedSkill findResolvedSkillById(Long skillId) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java b/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java new file mode 100644 index 00000000..35ce9eeb --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java @@ -0,0 +1,495 @@ +package vip.mate.skill.acp; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import vip.mate.acp.event.AcpEndpointChangedEvent; +import vip.mate.acp.model.AcpEndpointEntity; +import vip.mate.acp.service.AcpDelegationService; +import vip.mate.acp.service.AcpEndpointService; +import vip.mate.skill.knowledge.SkillScopedToolCallback; +import vip.mate.skill.manifest.SkillManifest; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.runtime.model.ResolvedSkill; +import vip.mate.tool.ToolRegistry; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * RFC-090 §3.2 / §14.4 (parallel) — ACP endpoint → virtual skill bridge. + * + *

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

This solves the "ACP configured as skill cannot be called" + * usability bug (matches the inspiration from QwenPaw's + * {@code delegate_external_agent} pattern, but keeps MateClaw's + * skill-card affordance for endpoint discovery): the user manages + * endpoints in Settings ▸ ACP Endpoints, and a card automatically + * appears on the Skills page — no per-endpoint SKILL.md authoring + * required. + * + *

Lifecycle: + *

    + *
  • {@link ApplicationReadyEvent} — initial wrapper registration + * for all enabled endpoints.
  • + *
  • {@link AcpEndpointChangedEvent} — re-sync registrations on + * create / update / toggle / delete.
  • + *
  • {@code list/list-status} entry points rebuild the virtual + * SkillEntity / ResolvedSkill snapshots on demand so the Skills + * page always shows current state.
  • + *
+ * + *

ID namespace: virtual skill ids use a high sentinel + * {@link #VIRTUAL_ID_BASE} different from the MCP bridge's, so the two + * id spaces never collide and a callsite can dispatch on which bridge + * owns an id without coordination. + */ +@Slf4j +@Service +public class AcpSkillBridge { + + /** + * High sentinel for ACP virtual id space. Distinct from + * {@code McpSkillBridge.VIRTUAL_ID_BASE} (9e18) so the two virtual + * spaces are partitionable by simple range checks. + */ + public static final long VIRTUAL_ID_BASE = 8_000_000_000_000_000_000L; + /** Upper bound, exclusive — anything in [BASE, BASE + 1e17) is ours. */ + public static final long VIRTUAL_ID_BOUND = VIRTUAL_ID_BASE + 100_000_000_000_000_000L; + + private final AcpEndpointService endpointService; + private final AcpDelegationService delegationService; + private final ObjectMapper objectMapper; + private final ToolRegistry toolRegistry; + + /** + * endpointId → set of registered wrapper tool names. Keeps the tool + * registry synced with the live endpoint set: when an endpoint is + * toggled off/deleted, we know exactly which tools to remove. + */ + private final ConcurrentHashMap> registeredWrappers = new ConcurrentHashMap<>(); + + @Autowired + public AcpSkillBridge(AcpEndpointService endpointService, + AcpDelegationService delegationService, + ObjectMapper objectMapper, + @Lazy ToolRegistry toolRegistry) { + this.endpointService = endpointService; + this.delegationService = delegationService; + this.objectMapper = objectMapper; + this.toolRegistry = toolRegistry; + } + + public static boolean isVirtualAcpSkillId(Long id) { + return id != null && id >= VIRTUAL_ID_BASE && id < VIRTUAL_ID_BOUND; + } + + public static Long extractEndpointId(Long virtualId) { + if (!isVirtualAcpSkillId(virtualId)) return null; + return virtualId - VIRTUAL_ID_BASE; + } + + public static long virtualIdFor(AcpEndpointEntity endpoint) { + return VIRTUAL_ID_BASE + endpoint.getId(); + } + + @PostConstruct + public void init() { + log.info("AcpSkillBridge initialized (virtual ID range: {}+endpointId)", VIRTUAL_ID_BASE); + } + + /** + * Initial registration: enumerate enabled endpoints once the + * application is fully bootstrapped. {@link ApplicationReadyEvent} + * is preferred over {@code @PostConstruct} because the database + * bootstrap (Flyway + seed data) finishes only after the context + * comes up, and the endpoint table may be empty before that. + */ + @EventListener(ApplicationReadyEvent.class) + public void onReady() { + try { + int registered = 0; + for (AcpEndpointEntity ep : endpointService.listEnabled()) { + registerWrappers(ep); + registered++; + } + if (registered > 0) { + log.info("AcpSkillBridge: registered wrappers for {} enabled endpoint(s)", registered); + } + } catch (Exception e) { + log.warn("AcpSkillBridge initial registration failed: {}", e.getMessage()); + } + } + + /** + * Resync wrapper registrations on every endpoint lifecycle event. + * Disabled / deleted endpoints have their wrappers removed; enabled + * endpoints are re-registered (idempotent — we deregister-then-add + * to avoid double registration when the user re-enables a row). + */ + @EventListener(AcpEndpointChangedEvent.class) + public void onEndpointChanged(AcpEndpointChangedEvent event) { + Long endpointId = event.endpointId(); + if (endpointId == null) return; + try { + switch (event.type()) { + case DELETED -> deregisterWrappers(endpointId); + case CREATED, UPDATED, TOGGLED -> { + AcpEndpointEntity ep = safeGet(endpointId); + if (ep == null || !Boolean.TRUE.equals(ep.getEnabled())) { + deregisterWrappers(endpointId); + } else { + // Idempotent: drop stale set, then build fresh — + // covers args/env edits where the wrapper itself + // doesn't change but we still want a clean slate. + deregisterWrappers(endpointId); + registerWrappers(ep); + } + } + } + } catch (Exception e) { + log.warn("AcpSkillBridge resync for endpoint '{}' ({}) failed: {}", + event.name(), event.type(), e.getMessage()); + } + } + + /** + * Snapshot every enabled endpoint as a virtual {@link SkillEntity}. + * Used by the Skills list endpoint; rows are non-persistent and + * regenerated on each call. + */ + public List listAcpDerivedSkillEntities() { + return safeListEnabled().stream().map(this::endpointToEntity).toList(); + } + + /** + * Snapshot every enabled endpoint as a virtual {@link ResolvedSkill} + * with synthesized manifest. Status reflects the last connection + * test on the row: OK → READY, ERROR / unknown → SETUP_NEEDED. + */ + public List listAcpDerivedResolvedSkills() { + return safeListEnabled().stream().map(this::endpointToResolved).toList(); + } + + /** + * Lookup a single virtual ResolvedSkill by virtual id. Used by the + * Skill detail drawer's reverse lookup path. + */ + public ResolvedSkill findResolvedById(Long virtualId) { + Long endpointId = extractEndpointId(virtualId); + if (endpointId == null) return null; + AcpEndpointEntity ep = safeGet(endpointId); + return ep != null && Boolean.TRUE.equals(ep.getEnabled()) ? endpointToResolved(ep) : null; + } + + public SkillEntity findEntityById(Long virtualId) { + Long endpointId = extractEndpointId(virtualId); + if (endpointId == null) return null; + AcpEndpointEntity ep = safeGet(endpointId); + return ep != null && Boolean.TRUE.equals(ep.getEnabled()) ? endpointToEntity(ep) : null; + } + + // ==================== Tool registration ==================== + + private void registerWrappers(AcpEndpointEntity ep) { + if (ep == null || !Boolean.TRUE.equals(ep.getEnabled())) return; + String slug = slugify(ep.getName()); + if (slug.isEmpty()) { + log.warn("ACP endpoint id={} has blank name; cannot register wrapper", ep.getId()); + return; + } + String toolName = "acp_" + slug + "_prompt"; + String desc = String.format( + "Delegate a prompt to the '%s' ACP coding agent. " + + "Send a single-string instruction; receive the agent's final reply.%s", + ep.getName(), + ep.getDescription() == null || ep.getDescription().isBlank() + ? "" + : " — " + ep.getDescription()); + // cwd stays optional in the schema so the LLM doesn't have to + // invent a path. The server defaults to the endpoint's bound + // workspace base_path when omitted (see AcpRuntimeSupport). + String schema = "{" + + "\"type\":\"object\"," + + "\"properties\":{" + + "\"prompt\":{\"type\":\"string\",\"description\":\"the instruction or question to send\"}," + + "\"cwd\":{\"type\":\"string\",\"description\":\"optional working directory; " + + "defaults to the endpoint's workspace base path when omitted\"}" + + "}," + + "\"required\":[\"prompt\"]" + + "}"; + + final String endpointName = ep.getName(); + final Long endpointId = ep.getId(); + + SkillScopedToolCallback callback = new SkillScopedToolCallback(toolName, desc, schema, input -> { + try { + JsonNode args = input == null || input.isBlank() + ? objectMapper.createObjectNode() + : objectMapper.readTree(input); + String userPrompt = args.path("prompt").asText("").trim(); + if (userPrompt.isEmpty()) return errorJson("prompt is required"); + String cwdHint = args.path("cwd").asText(""); + String reply = delegationService.prompt(endpointName, userPrompt, + cwdHint == null || cwdHint.isBlank() ? null : cwdHint); + JSONObject resp = new JSONObject() + .set("endpoint", endpointName) + .set("reply", reply); + return JSONUtil.toJsonStr(resp); + } catch (Exception e) { + log.warn("ACP wrapper '{}' failed: {}", toolName, e.getMessage()); + return errorJson(e.getMessage() == null ? "delegation failed" : e.getMessage()); + } + }); + + // Availability supplier: re-check each agent tool-set build so + // a toggle-off without a deregister call still hides the tool. + toolRegistry.registerPluginTool(callback, () -> { + AcpEndpointEntity live = safeGet(endpointId); + return live != null && Boolean.TRUE.equals(live.getEnabled()); + }); + registeredWrappers.computeIfAbsent(endpointId, k -> ConcurrentHashMap.newKeySet()).add(toolName); + log.info("AcpSkillBridge: registered wrapper '{}' for endpoint '{}'", toolName, endpointName); + } + + private void deregisterWrappers(Long endpointId) { + Set names = registeredWrappers.remove(endpointId); + if (names == null || names.isEmpty()) return; + for (String name : names) { + try { + toolRegistry.unregisterPluginTool(name); + } catch (Exception e) { + log.debug("Unregister ACP wrapper '{}' failed: {}", name, e.getMessage()); + } + } + log.info("AcpSkillBridge: deregistered {} wrapper(s) for endpoint id={}", names.size(), endpointId); + } + + // ==================== Synthesis ==================== + + private SkillEntity endpointToEntity(AcpEndpointEntity ep) { + SkillEntity s = new SkillEntity(); + s.setId(virtualIdFor(ep)); + s.setName(slugify(ep.getName())); + s.setNameEn(displayName(ep)); + s.setNameZh(ep.getDescription() != null && !ep.getDescription().isBlank() + ? displayName(ep) : null); + s.setDescription(buildDescription(ep)); + s.setSkillType("acp"); + s.setIcon(iconFor(ep)); + s.setVersion("1.0.0"); + s.setAuthor("acp-bridge"); + s.setEnabled(Boolean.TRUE.equals(ep.getEnabled())); + s.setBuiltin(Boolean.TRUE.equals(ep.getBuiltin())); + s.setTags("acp"); + s.setSecurityScanStatus("PASSED"); // ACP endpoints are user-configured external CLIs, not skill scripts + s.setConfigJson(buildConfigJson(ep)); + s.setManifestJson(serializeManifest(buildManifest(ep))); + return s; + } + + private ResolvedSkill endpointToResolved(AcpEndpointEntity ep) { + SkillManifest manifest = buildManifest(ep); + boolean ok = "OK".equalsIgnoreCase(nullSafe(ep.getLastStatus())); + boolean errored = "ERROR".equalsIgnoreCase(nullSafe(ep.getLastStatus())) + || (ep.getLastError() != null && !ep.getLastError().isBlank()); + // "Unknown" (untested) endpoints are treated as READY so the user + // can call them without an explicit Test click — unlike MCP, an + // ACP CLI is spawned per call so an untested-but-installed CLI + // works fine on first invocation. ERROR keeps SETUP_NEEDED. + boolean ready = !errored; + + Map featureStatuses = new LinkedHashMap<>(); + featureStatuses.put("default", ready ? "READY" : "SETUP_NEEDED"); + Set active = new LinkedHashSet<>(); + if (ready) active.add("default"); + + List missing = new ArrayList<>(); + if (!ready) { + missing.add("acp:" + ep.getName() + " (status: " + nullSafe(ep.getLastStatus()) + ")"); + } + + String summary = ok + ? "ACP endpoint '" + ep.getName() + "' tested OK" + : (errored + ? "ACP endpoint '" + ep.getName() + "' last test failed: " + nullSafe(ep.getLastError()) + : "ACP endpoint '" + ep.getName() + "' not yet tested — calls will spawn the CLI on demand"); + + return ResolvedSkill.builder() + .id(virtualIdFor(ep)) + .name(slugify(ep.getName())) + .description(buildDescription(ep)) + .content("") // no SKILL.md + .source("acp") + .skillDir(null) + .configuredSkillDir(null) + .runtimeAvailable(ready) + .resolutionError(ready ? null : nullSafe(ep.getLastError())) + .references(Map.of()) + .scripts(Map.of()) + .enabled(Boolean.TRUE.equals(ep.getEnabled())) + .icon(iconFor(ep)) + .builtin(Boolean.TRUE.equals(ep.getBuiltin())) + .securityBlocked(false) + .securitySummary("ACP-derived skill (external CLI; not subject to SKILL.md scanning)") + .dependencyReady(ready) + .missingDependencies(missing) + .dependencySummary(summary) + .manifest(manifest) + .featureStatuses(featureStatuses) + .activeFeatures(active) + .build(); + } + + /** + * Synthesize a §10.2 minimal manifest from the live endpoint row. + * The wrapper tool name {@code acp__prompt} is the single + * advertised tool, surfaced via {@code allowedTools} + the default + * feature so {@code ResolvedSkill.getEffectiveAllowedTools()} picks + * it up the same way as a hand-authored skill manifest. + */ + private SkillManifest buildManifest(AcpEndpointEntity ep) { + String slug = slugify(ep.getName()); + String toolName = "acp_" + slug + "_prompt"; + List tools = List.of(toolName); + + SkillManifest.FeatureDef defaultFeature = SkillManifest.FeatureDef.builder() + .id("default") + .label(displayName(ep)) + .requires(List.of("acp:" + ep.getName())) + .platforms(List.of()) + .tools(tools) + .build(); + + SkillManifest.RequirementDef acpRequirement = SkillManifest.RequirementDef.builder() + .key("acp:" + ep.getName()) + .type("acp") + .check(ep.getName()) + .description("ACP endpoint '" + ep.getName() + "' must be enabled and reachable. " + + "Configure in Settings ▸ ACP Endpoints.") + .build(); + + SkillManifest.AcpBinding binding = SkillManifest.AcpBinding.builder() + .endpoint(ep.getName()) + .resolvedEndpointId(ep.getId()) + .build(); + + return SkillManifest.builder() + .id(slug) + .name(slug) + .description(buildDescription(ep)) + .icon(iconFor(ep)) + .version("1.0.0") + .author("acp-bridge") + .type("acp") + .category("system") + .allowedTools(tools) + .requires(List.of(acpRequirement)) + .features(List.of(defaultFeature)) + .acp(binding) + .selfEvolution(SkillManifest.SelfEvolution.builder() + // Bridged ACP cards don't author LESSONS.md — the + // upstream agent owns its own self-evolution. + .lessonsEnabled(false) + .lessonsMaxEntries(0) + .memoryWritesAllowed(true) + .build()) + .extras(Map.of("acpEndpointId", ep.getId())) + .build(); + } + + // ==================== Helpers ==================== + + private List safeListEnabled() { + try { + return endpointService.listEnabled(); + } catch (Exception e) { + log.warn("AcpSkillBridge could not list enabled endpoints: {}", e.getMessage()); + return Collections.emptyList(); + } + } + + private AcpEndpointEntity safeGet(Long id) { + try { + return endpointService.get(id); + } catch (Exception e) { + return null; + } + } + + private String slugify(String raw) { + if (raw == null) return ""; + return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-"); + } + + private String displayName(AcpEndpointEntity ep) { + if (ep.getDisplayName() != null && !ep.getDisplayName().isBlank()) return ep.getDisplayName(); + return ep.getName() != null ? ep.getName() : "acp-" + ep.getId(); + } + + private String buildDescription(AcpEndpointEntity ep) { + if (ep.getDescription() != null && !ep.getDescription().isBlank()) return ep.getDescription(); + return "ACP coding agent '" + ep.getName() + "' — delegate prompts via the " + + "auto-registered tool. Configure in Settings ▸ ACP Endpoints."; + } + + private String iconFor(AcpEndpointEntity ep) { + // Light heuristic — match the most popular ACP runners by name. + String n = nullSafe(ep.getName()).toLowerCase(Locale.ROOT); + if (n.contains("claude")) return "🟠"; + if (n.contains("codex") || n.contains("openai")) return "⚪"; + if (n.contains("qwen")) return "🔵"; + if (n.contains("gemini") || n.contains("google")) return "🟡"; + if (n.contains("opencode")) return "🟢"; + return "🤝"; + } + + private String buildConfigJson(AcpEndpointEntity ep) { + try { + return objectMapper.writeValueAsString(Map.of( + "acpEndpointId", ep.getId(), + "command", nullSafe(ep.getCommand()), + "trusted", Boolean.TRUE.equals(ep.getTrusted()), + "source", Map.of("type", "acp"))); + } catch (Exception e) { + return "{}"; + } + } + + private String serializeManifest(SkillManifest manifest) { + try { + return objectMapper.writeValueAsString(manifest); + } catch (Exception e) { + return null; + } + } + + private static String nullSafe(String s) { + return s == null ? "" : s; + } + + private static String errorJson(String msg) { + return JSONUtil.createObj().set("error", msg).toString(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index 73733b90..35703d9a 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -51,6 +51,7 @@ public class SkillController { private final AgentService agentService; private final AgentBindingService agentBindingService; private final vip.mate.skill.mcp.McpSkillBridge mcpSkillBridge; + private final vip.mate.skill.acp.AcpSkillBridge acpSkillBridge; @Operation(summary = "获取技能分页列表(RFC-042 §2.1)") @GetMapping @@ -93,6 +94,29 @@ public class SkillController { // Bridge failure must not break the Skills page. } } + // RFC-090 §3.2 (parallel) — same auto-bridge for ACP endpoints. + if (page == 1 && (skillType == null || skillType.isBlank() || "acp".equalsIgnoreCase(skillType))) { + try { + List acpSkills = acpSkillBridge.listAcpDerivedSkillEntities(); + if (!acpSkills.isEmpty()) { + String kw = keyword == null ? "" : keyword.trim().toLowerCase(); + List filtered = acpSkills.stream() + .filter(s -> kw.isEmpty() + || (s.getName() != null && s.getName().toLowerCase().contains(kw)) + || (s.getDescription() != null && s.getDescription().toLowerCase().contains(kw))) + .filter(s -> enabled == null || enabled.equals(s.getEnabled())) + .toList(); + if (!filtered.isEmpty()) { + java.util.List merged = new java.util.ArrayList<>(filtered); + merged.addAll(dbPage.getRecords()); + dbPage.setRecords(merged); + dbPage.setTotal(dbPage.getTotal() + filtered.size()); + } + } + } catch (Exception e) { + // Bridge failure must not break the Skills page. + } + } return R.ok(dbPage); } @@ -112,6 +136,15 @@ public class SkillController { } catch (Exception ignored) { // Bridge failure must not break the badge fetch. } + try { + long virtualAcp = acpSkillBridge.listAcpDerivedSkillEntities().size(); + if (virtualAcp > 0) { + result.merge("acp", virtualAcp, Long::sum); + result.merge("all", virtualAcp, Long::sum); + } + } catch (Exception ignored) { + // Same defensive stance as the MCP bridge above. + } return R.ok(result); } @@ -152,6 +185,11 @@ public class SkillController { .map(R::ok) .orElse(R.fail("MCP-derived skill not found: " + id)); } + // RFC-090 §3.2 (parallel) — same path for ACP-derived virtual skills. + if (vip.mate.skill.acp.AcpSkillBridge.isVirtualAcpSkillId(id)) { + SkillEntity ent = acpSkillBridge.findEntityById(id); + return ent != null ? R.ok(ent) : R.fail("ACP-derived skill not found: " + id); + } return R.ok(skillService.getSkill(id)); } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java index 24775ba6..7178deda 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java @@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; +import vip.mate.skill.acp.AcpSkillBridge; import vip.mate.skill.lessons.SkillLessonsService; import vip.mate.skill.manifest.SkillManifest; import vip.mate.skill.mcp.McpSkillBridge; @@ -47,16 +48,23 @@ public class SkillRuntimeService { * which boots later in the lifecycle. */ private final McpSkillBridge mcpSkillBridge; + /** + * RFC-090 §3.2 (parallel) — ACP-endpoint → virtual-skill bridge. + * Same {@code @Lazy} treatment as the MCP bridge. + */ + private final AcpSkillBridge acpSkillBridge; @Autowired public SkillRuntimeService(SkillService skillService, SkillPackageResolver packageResolver, @Lazy SkillLessonsService lessonsService, - @Lazy McpSkillBridge mcpSkillBridge) { + @Lazy McpSkillBridge mcpSkillBridge, + @Lazy AcpSkillBridge acpSkillBridge) { this.skillService = skillService; this.packageResolver = packageResolver; this.lessonsService = lessonsService; this.mcpSkillBridge = mcpSkillBridge; + this.acpSkillBridge = acpSkillBridge; } // 缓存已解析的 active skills(5分钟过期) @@ -144,6 +152,14 @@ public class SkillRuntimeService { } catch (Exception e) { log.warn("MCP skill bridge active merge failed: {}", e.getMessage()); } + try { + // RFC-090 §3.2 (parallel) — ACP-derived virtual skills. + for (ResolvedSkill virt : acpSkillBridge.listAcpDerivedResolvedSkills()) { + if (passesActiveGate(virt)) resolved.add(virt); + } + } catch (Exception e) { + log.warn("ACP skill bridge active merge failed: {}", e.getMessage()); + } activeSkillsCache.put(CACHE_KEY, resolved); log.info("Refreshed active skills: {} enabled", resolved.size()); @@ -169,6 +185,11 @@ public class SkillRuntimeService { } catch (Exception e) { log.warn("MCP skill bridge merge failed: {}", e.getMessage()); } + try { + resolved.addAll(acpSkillBridge.listAcpDerivedResolvedSkills()); + } catch (Exception e) { + log.warn("ACP skill bridge merge failed: {}", e.getMessage()); + } return resolved; } diff --git a/mateclaw-ui/src/composables/useMarkdownRenderer.ts b/mateclaw-ui/src/composables/useMarkdownRenderer.ts index 142ca5f0..a1bfc94d 100644 --- a/mateclaw-ui/src/composables/useMarkdownRenderer.ts +++ b/mateclaw-ui/src/composables/useMarkdownRenderer.ts @@ -258,9 +258,18 @@ const customRenderer = { // Dangerous scheme — render the inner content as plain content (no anchor). return innerHtml } + // Defense against LLMs hallucinating a host on tool-returned download URLs. + // /api/v1/files/generated/ is always same-origin; multiple models have + // been observed prepending bogus schemes/hosts (https://localhost:8080, + // https://ai-tools-system.com, …) when echoing the URL back, breaking the + // download. Strip any prepended scheme://host so the link works regardless + // of what the model wrote. + const hostStripped = /^https?:\/\/[^/]+(\/api\/v1\/files\/generated\/.+)$/i.exec(href) + const safeHref = hostStripped ? hostStripped[1] : href + let extra = '' try { - const url = new URL(href, typeof window !== 'undefined' ? window.location.href : 'http://localhost/') + const url = new URL(safeHref, typeof window !== 'undefined' ? window.location.href : 'http://localhost/') if (typeof window !== 'undefined' && url.origin !== window.location.origin) { extra = ' target="_blank" rel="noopener noreferrer"' } @@ -268,7 +277,7 @@ const customRenderer = { // Malformed URL — treat as same-origin (relative link path). } const titleAttr = title ? ` title="${escapeHtml(title)}"` : '' - return `${innerHtml}` + return `${innerHtml}` }, } diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 6773df81..ec2d22e2 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1149,10 +1149,28 @@ export default { description: 'Description', command: 'Command', args: 'Args (JSON array)', - env: 'Env (JSON object)', + env: 'Environment variables', toolParseMode: 'Tool parse mode', enabled: 'Enabled', }, + env: { + formMode: 'Edit as JSON', + jsonMode: 'Edit as form', + suggested: 'Suggested', + addRow: 'Add variable', + keyPlaceholder: 'KEY_NAME', + valuePlaceholder: 'value (e.g. sk-…)', + show: 'Show', + hide: 'Hide', + remove: 'Remove', + hints: { + anthropic: 'Anthropic API key (sk-ant-…). Get one at https://console.anthropic.com/settings/keys', + openai: 'OpenAI API key (sk-…). Get one at https://platform.openai.com/api-keys', + dashscope: 'Aliyun DashScope key — same value as MateClaw .env DASHSCOPE_API_KEY', + google: 'Google AI Studio API key. Get one at https://aistudio.google.com/apikey', + claudeOauth: 'Heads up: claude-code OAuth login (the one stored in ~/.claude/) does NOT work here, and worse — the Claude Agent SDK reads it BEFORE this env var, silently shadowing your API key. If you have ever run `claude login` on this host, clear the keychain first: (macOS) `claude logout` or `security delete-generic-password -s "Claude Code-credentials"`; (Linux/Windows) delete ~/.claude/credentials.json. Then put a real API key (sk-ant-…) above. Or switch to qwen-code / opencode to reuse a CLI login.', + }, + }, modal: { newTitle: 'Add ACP Endpoint', editTitle: 'Edit ACP Endpoint', @@ -2000,6 +2018,7 @@ export default { all: 'All', builtin: 'Built-in', mcp: 'MCP', + acp: 'ACP', dynamic: 'Dynamic', }, search: { @@ -2073,6 +2092,7 @@ export default { types: { builtin: 'Built-in', mcp: 'MCP', + acp: 'ACP', dynamic: 'Dynamic', }, source: { diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 288c61d5..dd04f824 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -277,7 +277,7 @@ export default { mcpConnections: 'MCP 连接', toolsCatalog: '工具目录', activity: '活动记录', - acpEndpoints: 'ACP Endpoints', + acpEndpoints: 'ACP 端点', settingsGroup: '设置', agents: '智能体', security: '安全', @@ -1121,23 +1121,23 @@ export default { }, acp: { kicker: '外部 Agent', - title: 'ACP Endpoints', + title: 'ACP 端点', desc: '通过 stdio 委派给外部编码 Agent(codex / claude-code / opencode / qwen-code)。安装对应 CLI 后开启该入口。', - addEndpoint: '新增 Endpoint', + addEndpoint: '新增端点', builtin: '内置', test: '测试', testing: '测试中…', statusUnknown: '未测试', - empty: '暂无 ACP endpoint。', - loadFailed: '加载 ACP endpoint 列表失败', - saveFailed: '保存 endpoint 失败', + empty: '暂无 ACP 端点。', + loadFailed: '加载 ACP 端点列表失败', + saveFailed: '保存端点失败', deleteTitle: '确认删除', - deleteConfirm: '确认删除 ACP endpoint "{name}"?内置 endpoint 不可删除。', - deleteFailed: '删除 endpoint 失败', - toggleFailed: '切换 endpoint 状态失败', + deleteConfirm: '确认删除 ACP 端点 "{name}"?内置端点不可删除。', + deleteFailed: '删除端点失败', + toggleFailed: '切换端点状态失败', invalidJson: 'JSON 格式错误', columns: { - name: 'Endpoint', + name: '端点', command: '命令', status: '最近一次测试', enabled: '启用', @@ -1149,13 +1149,31 @@ export default { description: '描述', command: '命令', args: '参数 (JSON 数组)', - env: '环境变量 (JSON 对象)', + env: '环境变量', toolParseMode: 'Tool 解析模式', enabled: '启用', }, + env: { + formMode: '编辑 JSON', + jsonMode: '回到表单', + suggested: '推荐', + addRow: '添加变量', + keyPlaceholder: 'KEY_NAME', + valuePlaceholder: '值(如 sk-…)', + show: '显示', + hide: '隐藏', + remove: '移除', + hints: { + anthropic: 'Anthropic API key(sk-ant-…)。申请: https://console.anthropic.com/settings/keys', + openai: 'OpenAI API key(sk-…)。申请: https://platform.openai.com/api-keys', + dashscope: '阿里云 DashScope key — 跟 MateClaw .env 里的 DASHSCOPE_API_KEY 同一个值', + google: 'Google AI Studio API key。申请: https://aistudio.google.com/apikey', + claudeOauth: '注意: claude-code 的 OAuth 登录(~/.claude/ 里那个)在这里不能用——更糟的是 Claude Agent SDK 会"优先读 OAuth、再读 API key",导致你下面填的 key 被静默忽略。如果你这台机器上跑过 `claude login`,先清钥匙串: (macOS) `claude logout` 或 `security delete-generic-password -s "Claude Code-credentials"`;(Linux/Windows) 删 ~/.claude/credentials.json。然后再回来填真正的 API key(sk-ant-…)。或者改用 qwen-code / opencode 复用各自 CLI 的本地登录态。', + }, + }, modal: { - newTitle: '新增 ACP Endpoint', - editTitle: '编辑 ACP Endpoint', + newTitle: '新增 ACP 端点', + editTitle: '编辑 ACP 端点', }, }, mcp: { @@ -2002,6 +2020,7 @@ export default { all: '全部', builtin: '内置', mcp: 'MCP', + acp: 'ACP', dynamic: '动态', }, search: { @@ -2075,6 +2094,7 @@ export default { types: { builtin: '内置', mcp: 'MCP', + acp: 'ACP', dynamic: '动态', }, source: { diff --git a/mateclaw-ui/src/views/AcpEndpoints.vue b/mateclaw-ui/src/views/AcpEndpoints.vue index 0c75dcb9..6ff67cbb 100644 --- a/mateclaw-ui/src/views/AcpEndpoints.vue +++ b/mateclaw-ui/src/views/AcpEndpoints.vue @@ -131,8 +131,81 @@

- - +
+ + +
+ + +
+ {{ t('acp.env.suggested') }}: + +
+ + +
+
+ +
+ + +
+ +
+ +
+ + + + + +
💡 {{ envHint }}