mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
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.
193 lines
8.2 KiB
Java
193 lines
8.2 KiB
Java
package vip.mate.acp.service;
|
|
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
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;
|
|
|
|
import java.time.LocalDateTime;
|
|
import java.util.Collections;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
|
|
/**
|
|
* RFC-090 Phase 7 — CRUD layer for {@link AcpEndpointEntity}.
|
|
*
|
|
* <p>Keeps three guarantees:
|
|
* <ol>
|
|
* <li>Builtin rows ({@code builtin=true}) cannot be hard-deleted —
|
|
* the user can only disable them. Mirrors {@code SkillService}.</li>
|
|
* <li>Names are unique; {@code create} validates against the live
|
|
* (non-deleted) set.</li>
|
|
* <li>{@code argsJson} / {@code envJson} round-trip through Jackson
|
|
* so the controller can hand structured data to the UI without
|
|
* leaking string-encoded JSON.</li>
|
|
* </ol>
|
|
*/
|
|
@Slf4j
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
public class AcpEndpointService {
|
|
|
|
private final AcpEndpointMapper mapper;
|
|
private final ObjectMapper objectMapper;
|
|
private final ApplicationEventPublisher eventPublisher;
|
|
|
|
public List<AcpEndpointEntity> list() {
|
|
return mapper.selectList(new LambdaQueryWrapper<AcpEndpointEntity>()
|
|
.orderByDesc(AcpEndpointEntity::getBuiltin)
|
|
.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<AcpEndpointEntity> listEnabled() {
|
|
return mapper.selectList(new LambdaQueryWrapper<AcpEndpointEntity>()
|
|
.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",
|
|
"ACP endpoint not found: " + id);
|
|
return ep;
|
|
}
|
|
|
|
public AcpEndpointEntity findByName(String name) {
|
|
return mapper.selectOne(new LambdaQueryWrapper<AcpEndpointEntity>()
|
|
.eq(AcpEndpointEntity::getName, name));
|
|
}
|
|
|
|
public AcpEndpointEntity create(AcpEndpointEntity input) {
|
|
if (input.getName() == null || input.getName().isBlank()) {
|
|
throw new MateClawException("err.acp.name_required", "ACP endpoint name is required");
|
|
}
|
|
if (input.getCommand() == null || input.getCommand().isBlank()) {
|
|
throw new MateClawException("err.acp.command_required", "ACP endpoint command is required");
|
|
}
|
|
if (findByName(input.getName()) != null) {
|
|
throw new MateClawException("err.acp.name_exists",
|
|
"ACP endpoint name already exists: " + input.getName());
|
|
}
|
|
// User-created rows are never builtin; default-enable false so a
|
|
// misconfigured row can't auto-spawn a process at startup.
|
|
input.setBuiltin(false);
|
|
if (input.getEnabled() == null) input.setEnabled(false);
|
|
if (input.getTrusted() == null) input.setTrusted(true);
|
|
if (input.getToolParseMode() == null || input.getToolParseMode().isBlank()) {
|
|
input.setToolParseMode("call_title");
|
|
}
|
|
if (input.getStdioBufferLimitBytes() == null || input.getStdioBufferLimitBytes() <= 0) {
|
|
input.setStdioBufferLimitBytes(50L * 1024L * 1024L);
|
|
}
|
|
if (input.getWorkspaceId() == null) input.setWorkspaceId(1L);
|
|
mapper.insert(input);
|
|
log.info("Created ACP endpoint: {}", input.getName());
|
|
publish(input, AcpEndpointChangedEvent.Type.CREATED);
|
|
return input;
|
|
}
|
|
|
|
public AcpEndpointEntity update(Long id, AcpEndpointEntity patch) {
|
|
AcpEndpointEntity existing = get(id);
|
|
if (Boolean.TRUE.equals(existing.getBuiltin())
|
|
&& patch.getCommand() != null
|
|
&& !patch.getCommand().equals(existing.getCommand())) {
|
|
throw new MateClawException("err.acp.builtin_command_locked",
|
|
"Builtin ACP endpoint command cannot be changed: " + existing.getName());
|
|
}
|
|
// Allow surgical updates: only fields the caller actually set.
|
|
if (patch.getDisplayName() != null) existing.setDisplayName(patch.getDisplayName());
|
|
if (patch.getDescription() != null) existing.setDescription(patch.getDescription());
|
|
if (patch.getCommand() != null) existing.setCommand(patch.getCommand());
|
|
if (patch.getArgsJson() != null) existing.setArgsJson(patch.getArgsJson());
|
|
if (patch.getEnvJson() != null) existing.setEnvJson(patch.getEnvJson());
|
|
if (patch.getToolParseMode() != null) existing.setToolParseMode(patch.getToolParseMode());
|
|
if (patch.getTrusted() != null) existing.setTrusted(patch.getTrusted());
|
|
if (patch.getEnabled() != null) existing.setEnabled(patch.getEnabled());
|
|
if (patch.getStdioBufferLimitBytes() != null && patch.getStdioBufferLimitBytes() > 0) {
|
|
existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes());
|
|
}
|
|
mapper.updateById(existing);
|
|
publish(existing, AcpEndpointChangedEvent.Type.UPDATED);
|
|
return existing;
|
|
}
|
|
|
|
public void delete(Long id) {
|
|
AcpEndpointEntity existing = get(id);
|
|
if (Boolean.TRUE.equals(existing.getBuiltin())) {
|
|
throw new MateClawException("err.acp.builtin_readonly",
|
|
"Builtin ACP endpoint cannot be deleted: " + existing.getName());
|
|
}
|
|
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);
|
|
if (existing == null) return;
|
|
existing.setLastStatus(status);
|
|
existing.setLastTestedAt(LocalDateTime.now());
|
|
existing.setLastError(error);
|
|
mapper.updateById(existing);
|
|
}
|
|
|
|
public List<String> parseArgs(AcpEndpointEntity ep) {
|
|
return parseStringList(ep.getArgsJson());
|
|
}
|
|
|
|
public Map<String, String> parseEnv(AcpEndpointEntity ep) {
|
|
if (ep.getEnvJson() == null || ep.getEnvJson().isBlank()) return Map.of();
|
|
try {
|
|
return objectMapper.readValue(ep.getEnvJson(),
|
|
new TypeReference<Map<String, String>>() {});
|
|
} catch (Exception e) {
|
|
log.warn("Failed to parse env_json for ACP endpoint '{}': {}",
|
|
ep.getName(), e.getMessage());
|
|
return Map.of();
|
|
}
|
|
}
|
|
|
|
private List<String> parseStringList(String json) {
|
|
if (json == null || json.isBlank()) return Collections.emptyList();
|
|
try {
|
|
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
|
|
} catch (Exception e) {
|
|
log.warn("Failed to parse args_json: {}", e.getMessage());
|
|
return Collections.emptyList();
|
|
}
|
|
}
|
|
}
|