mateclaw/mateclaw-server/src/main/java/vip/mate/acp/service/AcpConnectionTester.java
matevip 5e6764ecf1 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?://<host> prefix from /api/v1/files/generated/<id> download
  links before building the <a href>. 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.
2026-05-01 12:24:25 +08:00

129 lines
5.8 KiB
Java

package vip.mate.acp.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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 java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* RFC-090 Phase 7 — connection tester for ACP endpoints.
*
* <p>Runs the {@code initialize} + {@code session/new} handshake, with
* a generous-but-bounded timeout, and persists the outcome on the row.
* The wired CLI doesn't have to be installed for the user to add a row;
* they can install it later and re-run the test.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AcpConnectionTester {
/** Hard cap so a hung CLI doesn't block the request thread forever. */
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;
private final AcpRuntimeSupport runtimeSupport;
/**
* Spawn the configured agent, exchange initialize + session/new,
* tear it down, and return a structured result. The endpoint row
* is updated with {@code last_status / last_tested_at / last_error}.
*/
public Map<String, Object> testEndpoint(AcpEndpointEntity endpoint) {
long started = System.currentTimeMillis();
Map<String, Object> result = new LinkedHashMap<>();
result.put("name", endpoint.getName());
result.put("command", endpoint.getCommand());
List<String> args = endpointService.parseArgs(endpoint);
Map<String, String> 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, resolvedCwd);
} catch (Exception e) {
return persistAndReturn(endpoint, result, "ERROR",
"Spawn failed: " + e.getMessage(), started);
}
try (AcpStdioClient autoClose = client) {
JsonNode initResp;
try {
initResp = autoClose.initialize(INITIALIZE_TIMEOUT_MS);
} catch (Exception e) {
return persistAndReturn(endpoint, result, "ERROR",
"Initialize failed: " + e.getMessage(), started);
}
if (initResp == null) {
return persistAndReturn(endpoint, result, "ERROR",
"Initialize returned no result", started);
}
int agentProtocolVersion = initResp.path("protocolVersion").asInt(-1);
result.put("protocolVersion", agentProtocolVersion);
if (agentProtocolVersion != AcpStdioClient.PROTOCOL_VERSION) {
String msg = "Protocol mismatch: agent=" + agentProtocolVersion
+ ", client=" + AcpStdioClient.PROTOCOL_VERSION;
return persistAndReturn(endpoint, result, "ERROR", msg, started);
}
// Capture agent capabilities for diagnostics — the UI can
// surface this as "supports: file_system, terminal, …".
JsonNode agentCaps = initResp.path("agentCapabilities");
if (!agentCaps.isMissingNode() && !agentCaps.isNull()) {
result.put("agentCapabilities", agentCaps);
}
// session/new validates that the agent really stands up a
// working session, not just initialize handshake.
try {
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 — 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",
"Connection test crashed: " + e.getMessage(), started);
}
long elapsed = System.currentTimeMillis() - started;
result.put("elapsedMs", elapsed);
return persistAndReturn(endpoint, result, "OK", null, started);
}
private Map<String, Object> persistAndReturn(AcpEndpointEntity endpoint,
Map<String, Object> result,
String status,
String error,
long started) {
endpointService.recordTestResult(endpoint.getId(), status, error);
result.put("status", status);
if (error != null) result.put("error", error);
result.putIfAbsent("elapsedMs", System.currentTimeMillis() - started);
return result;
}
}