mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(a2a): add agent interoperability protocol support
Add an A2A JSON-RPC endpoint with authenticated message/send, message/stream, tasks/get, and tasks/cancel handling. Expose anonymous minimal Agent Cards while keeping the enabled-agent skills list behind existing Bearer authentication. Bridge inbound calls into the existing agent runtime, add an in-memory task store with duplicate task rejection, JSON-RPC idempotency snapshots, terminal TTL cleanup, and SSE status/artifact event streaming with heartbeat comments. Add the call_a2a_agent tool and peer adapter with Agent Card discovery fallback, blocking task polling through tasks/get, event-boundary SSE parsing, response caps, timeout limits, redirect refusal, and private-network SSRF protection. Wire mateclaw.a2a configuration, document deployment settings in English and Chinese, mirror bundled docs, and cover task storage, JSON-RPC validation, card privacy, lifecycle/cancel behavior, SSE parsing, and outbound guardrails with focused tests.
This commit is contained in:
parent
a8a0b75bfa
commit
1159dcdbf3
@ -100,6 +100,8 @@ public class SecurityConfig {
|
||||
// KB Open API: authenticated by KbOpenApiAuthFilter (API key),
|
||||
// not JWT — must be permitAll so the filter is the sole gatekeeper (R1).
|
||||
"/api/v1/open/kb/**",
|
||||
"/api/a2a/card",
|
||||
"/.well-known/agent-card.json",
|
||||
"/api/v1/talk/ws",
|
||||
// Desktop local-tool tunnel — the handshake interceptor
|
||||
// authenticates the ?token= query param itself, so the
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class A2aAgentCardController {
|
||||
|
||||
private final A2aAgentCardService cardService;
|
||||
|
||||
@GetMapping("/api/a2a/card")
|
||||
public Map<String, Object> card(HttpServletRequest request,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
Authentication authentication) {
|
||||
if (authentication == null) {
|
||||
return cardService.publicCard(request);
|
||||
}
|
||||
return cardService.authenticatedCard(request, workspaceId);
|
||||
}
|
||||
|
||||
@GetMapping("/.well-known/agent-card.json")
|
||||
public Map<String, Object> wellKnown(HttpServletRequest request) {
|
||||
return cardService.publicCard(request);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class A2aAgentCardService {
|
||||
|
||||
private final A2aProperties properties;
|
||||
private final AgentService agentService;
|
||||
|
||||
public Map<String, Object> publicCard(HttpServletRequest request) {
|
||||
Map<String, Object> card = baseCard(request);
|
||||
card.put("supportsAuthenticatedExtendedCard", true);
|
||||
card.remove("skills");
|
||||
return card;
|
||||
}
|
||||
|
||||
public Map<String, Object> authenticatedCard(HttpServletRequest request, Long workspaceId) {
|
||||
long wsId = workspaceId == null ? 1L : workspaceId;
|
||||
Map<String, Object> card = baseCard(request);
|
||||
List<Map<String, Object>> skills = new ArrayList<>();
|
||||
for (AgentEntity agent : agentService.listAgentsByWorkspace(wsId, true)) {
|
||||
Map<String, Object> skill = new LinkedHashMap<>();
|
||||
skill.put("id", String.valueOf(agent.getId()));
|
||||
skill.put("name", agent.getName());
|
||||
skill.put("description", agent.getDescription() == null ? "" : agent.getDescription());
|
||||
skill.put("tags", tags(agent.getTags()));
|
||||
skills.add(skill);
|
||||
}
|
||||
card.put("skills", skills);
|
||||
return card;
|
||||
}
|
||||
|
||||
private Map<String, Object> baseCard(HttpServletRequest request) {
|
||||
String rpcUrl = externalBaseUrl(request).replaceAll("/+$", "") + "/api/a2a";
|
||||
Map<String, Object> card = new LinkedHashMap<>();
|
||||
card.put("name", "MateClaw");
|
||||
card.put("description", "A multi-agent runtime exposed through A2A JSON-RPC.");
|
||||
card.put("url", rpcUrl);
|
||||
card.put("version", "1.0.0");
|
||||
card.put("protocolVersion", "1.0");
|
||||
card.put("supportedInterfaces", List.of(Map.of(
|
||||
"url", rpcUrl,
|
||||
"protocolBinding", "JSONRPC",
|
||||
"protocolVersion", "1.0"
|
||||
)));
|
||||
card.put("capabilities", Map.of(
|
||||
"streaming", true,
|
||||
"pushNotifications", false,
|
||||
"stateTransitionHistory", false
|
||||
));
|
||||
card.put("defaultInputModes", List.of("text/plain"));
|
||||
card.put("defaultOutputModes", List.of("text/plain"));
|
||||
card.put("skills", List.of());
|
||||
return card;
|
||||
}
|
||||
|
||||
private String externalBaseUrl(HttpServletRequest request) {
|
||||
if (properties.getBaseUrl() != null && !properties.getBaseUrl().isBlank()) {
|
||||
return properties.getBaseUrl().trim();
|
||||
}
|
||||
return ServletUriComponentsBuilder.fromRequestUri(request)
|
||||
.replacePath(null)
|
||||
.replaceQuery(null)
|
||||
.build()
|
||||
.toUriString();
|
||||
}
|
||||
|
||||
private static List<String> tags(String tags) {
|
||||
if (tags == null || tags.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> out = new ArrayList<>();
|
||||
for (String tag : tags.split(",")) {
|
||||
String trimmed = tag.trim();
|
||||
if (!trimmed.isBlank()) {
|
||||
out.add(trimmed);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(A2aProperties.class)
|
||||
public class A2aAutoConfiguration {
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class A2aCallTool {
|
||||
|
||||
private final A2aPeerAdapter peerAdapter;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Tool(name = "call_a2a_agent", description = "Call another A2A-compatible agent. The config JSON must include url and may include headers.")
|
||||
public String callA2aAgent(
|
||||
@ToolParam(description = "Message to send to the peer agent") String message,
|
||||
@ToolParam(description = "Optional peer conversation context id", required = false) String contextId,
|
||||
@ToolParam(description = "Optional peer skill id", required = false) String skillId,
|
||||
@ToolParam(description = "JSON object: {\"url\":\"https://peer/api/a2a\",\"headers\":{\"Authorization\":\"Bearer ...\"},\"stream\":false}") String config
|
||||
) {
|
||||
try {
|
||||
Map<String, Object> cfg = parseConfig(config);
|
||||
String url = String.valueOf(cfg.getOrDefault("url", "")).trim();
|
||||
if (url.isBlank()) {
|
||||
return "Error: config.url is required.";
|
||||
}
|
||||
Map<String, String> headers = headers(cfg.get("headers"));
|
||||
boolean stream = Boolean.TRUE.equals(cfg.get("stream"));
|
||||
A2aPeerAdapter.PeerResult result = stream
|
||||
? peerAdapter.stream(url, message, contextId, skillId, headers)
|
||||
: peerAdapter.sendBlocking(url, message, contextId, skillId, headers);
|
||||
if (stream && !result.frames().isEmpty()) {
|
||||
return objectMapper.writeValueAsString(result.frames());
|
||||
}
|
||||
return result.body();
|
||||
} catch (Exception e) {
|
||||
return "Error: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> parseConfig(String config) throws Exception {
|
||||
if (config == null || config.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
return objectMapper.readValue(config, new TypeReference<LinkedHashMap<String, Object>>() {
|
||||
});
|
||||
}
|
||||
|
||||
private static Map<String, String> headers(Object value) {
|
||||
if (!(value instanceof Map<?, ?> raw)) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, String> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<?, ?> entry : raw.entrySet()) {
|
||||
if (entry.getKey() != null && entry.getValue() != null) {
|
||||
out.put(String.valueOf(entry.getKey()), String.valueOf(entry.getValue()));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
public interface A2aExecutionBridge {
|
||||
|
||||
ExecutionResult executeBlocking(A2aExecutionRequest request);
|
||||
|
||||
record ExecutionResult(String text, boolean terminal) {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
public record A2aExecutionRequest(
|
||||
String taskId,
|
||||
String contextId,
|
||||
String message,
|
||||
Long agentId,
|
||||
Long workspaceId,
|
||||
String username,
|
||||
Long userId
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,321 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/a2a")
|
||||
@RequiredArgsConstructor
|
||||
public class A2aJsonRpcController {
|
||||
|
||||
private static final int ERR_INVALID_REQUEST = -32600;
|
||||
private static final int ERR_METHOD_NOT_FOUND = -32601;
|
||||
private static final int ERR_INVALID_PARAMS = -32602;
|
||||
private static final int ERR_TASK_NOT_FOUND = -32001;
|
||||
private static final int ERR_DUPLICATE_TASK = -32009;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final A2aProperties properties;
|
||||
private final A2aTaskStore store;
|
||||
private final A2aExecutionBridge bridge;
|
||||
private final ExecutorService streamExecutor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<Object> handle(@RequestBody JsonNode body, Authentication authentication) {
|
||||
if (!properties.isEnabled()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "A2A is disabled"));
|
||||
}
|
||||
if (authentication == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error(null, -32050, "unauthorized"));
|
||||
}
|
||||
Object rpcId = rpcId(body == null ? null : body.get("id"));
|
||||
if (rpcId == InvalidRpcId.INSTANCE) {
|
||||
return ResponseEntity.ok(error(null, ERR_INVALID_REQUEST, "JSON-RPC id must be a string, number, or null"));
|
||||
}
|
||||
if (body == null || !body.isObject() || !"2.0".equals(text(body.get("jsonrpc")))) {
|
||||
return ResponseEntity.ok(error(rpcId, ERR_INVALID_REQUEST, "invalid JSON-RPC request"));
|
||||
}
|
||||
String method = text(body.get("method"));
|
||||
JsonNode params = body.get("params");
|
||||
String tenant = tenant(params);
|
||||
String rpcKey = rpcId == null ? null : String.valueOf(rpcId);
|
||||
if (rpcKey != null) {
|
||||
var existing = store.rpcSnapshot(tenant, rpcKey);
|
||||
if (existing.isPresent()) {
|
||||
return ResponseEntity.ok(result(rpcId, existing.get()));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return switch (method) {
|
||||
case "message/send" -> ResponseEntity.ok(handleSend(rpcId, tenant, params, authentication));
|
||||
case "message/stream" -> stream(rpcId, tenant, params, authentication);
|
||||
case "tasks/get" -> ResponseEntity.ok(handleGet(rpcId, tenant, params));
|
||||
case "tasks/cancel" -> ResponseEntity.ok(handleCancel(rpcId, tenant, params));
|
||||
default -> ResponseEntity.ok(error(rpcId, ERR_METHOD_NOT_FOUND, "method not found"));
|
||||
};
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.body(error(rpcId, -32051, e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> handleSend(Object rpcId, String tenant, JsonNode params, Authentication auth) {
|
||||
try {
|
||||
A2aExecutionRequest request = executionRequest(tenant, params, auth);
|
||||
A2aTask submitted = A2aTask.submitted(request.taskId(), request.contextId(), tenant);
|
||||
if (!store.putIfAbsent(tenant, submitted)) {
|
||||
return error(rpcId, ERR_DUPLICATE_TASK, "task id already exists");
|
||||
}
|
||||
A2aTask working = store.update(tenant, request.taskId(),
|
||||
task -> task.withStatus("working", null, false)).orElse(submitted);
|
||||
boolean blocking = !params.has("configuration")
|
||||
|| !params.get("configuration").has("blocking")
|
||||
|| params.get("configuration").get("blocking").asBoolean(true);
|
||||
A2aTask responseTask = blocking ? executeWithTimeout(tenant, request, working) : working;
|
||||
Map<String, Object> snapshot = responseTask.toMap();
|
||||
store.rememberRpcSnapshot(tenant, rpcId == null ? null : String.valueOf(rpcId), snapshot);
|
||||
return result(rpcId, snapshot);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return error(rpcId, ERR_INVALID_PARAMS, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<Object> stream(Object rpcId, String tenant, JsonNode params, Authentication auth) {
|
||||
SseEmitter emitter = new SseEmitter(0L);
|
||||
AtomicBoolean done = new AtomicBoolean(false);
|
||||
streamExecutor.execute(() -> heartbeat(emitter, done));
|
||||
streamExecutor.execute(() -> {
|
||||
try {
|
||||
A2aExecutionRequest request = executionRequest(tenant, params, auth);
|
||||
A2aTask submitted = A2aTask.submitted(request.taskId(), request.contextId(), tenant);
|
||||
if (!store.putIfAbsent(tenant, submitted)) {
|
||||
send(emitter, "error", error(rpcId, ERR_DUPLICATE_TASK, "task id already exists"));
|
||||
emitter.complete();
|
||||
return;
|
||||
}
|
||||
send(emitter, "task", submitted.toMap());
|
||||
A2aTask working = store.update(tenant, request.taskId(),
|
||||
task -> task.withStatus("working", null, false)).orElse(submitted);
|
||||
send(emitter, "status-update", working.toMap());
|
||||
A2aExecutionBridge.ExecutionResult out = bridge.executeBlocking(request);
|
||||
A2aTask withArtifact = store.update(tenant, request.taskId(),
|
||||
task -> task.withArtifact(out.text(), true)).orElse(working);
|
||||
send(emitter, "artifact-update", withArtifact.artifacts().getLast());
|
||||
A2aTask completed = store.update(tenant, request.taskId(),
|
||||
task -> task.withStatus(out.terminal() ? "completed" : "working", out.text(), out.terminal()))
|
||||
.orElse(withArtifact);
|
||||
send(emitter, "status-update", completed.toMap());
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
send(emitter, "error", error(rpcId, ERR_INVALID_PARAMS, e.getMessage()));
|
||||
} catch (IOException ignored) {
|
||||
// The client may already have disconnected.
|
||||
}
|
||||
emitter.completeWithError(e);
|
||||
} finally {
|
||||
done.set(true);
|
||||
}
|
||||
});
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.TEXT_EVENT_STREAM)
|
||||
.body(emitter);
|
||||
}
|
||||
|
||||
private Map<String, Object> handleGet(Object rpcId, String tenant, JsonNode params) {
|
||||
String taskId = taskId(params);
|
||||
return store.get(tenant, taskId)
|
||||
.<Map<String, Object>>map(task -> result(rpcId, task.toMap()))
|
||||
.orElseGet(() -> error(rpcId, ERR_TASK_NOT_FOUND, "task not found"));
|
||||
}
|
||||
|
||||
private Map<String, Object> handleCancel(Object rpcId, String tenant, JsonNode params) {
|
||||
String taskId = taskId(params);
|
||||
return store.update(tenant, taskId, task -> task.terminal()
|
||||
? task
|
||||
: task.withStatus("canceled", "Task canceled by caller.", true))
|
||||
.<Map<String, Object>>map(task -> result(rpcId, task.toMap()))
|
||||
.orElseGet(() -> error(rpcId, ERR_TASK_NOT_FOUND, "task not found"));
|
||||
}
|
||||
|
||||
private A2aTask executeWithTimeout(String tenant, A2aExecutionRequest request, A2aTask current) {
|
||||
CompletableFuture<A2aExecutionBridge.ExecutionResult> future =
|
||||
CompletableFuture.supplyAsync(() -> bridge.executeBlocking(request));
|
||||
try {
|
||||
A2aExecutionBridge.ExecutionResult out = future.get(properties.getCallTimeoutMs(), TimeUnit.MILLISECONDS);
|
||||
A2aTask withArtifact = store.update(tenant, request.taskId(),
|
||||
task -> task.withArtifact(out.text(), true)).orElse(current);
|
||||
return store.update(tenant, request.taskId(),
|
||||
task -> task.withStatus(out.terminal() ? "completed" : "working", out.text(), out.terminal()))
|
||||
.orElse(withArtifact);
|
||||
} catch (TimeoutException e) {
|
||||
return current;
|
||||
} catch (Exception e) {
|
||||
return store.update(tenant, request.taskId(),
|
||||
task -> task.withStatus("failed", e.getMessage(), true)).orElse(current);
|
||||
}
|
||||
}
|
||||
|
||||
private A2aExecutionRequest executionRequest(String tenant, JsonNode params, Authentication auth) {
|
||||
if (params == null || !params.isObject()) {
|
||||
throw new IllegalArgumentException("params object is required");
|
||||
}
|
||||
JsonNode message = params.get("message");
|
||||
if (message == null || !message.isObject()) {
|
||||
throw new IllegalArgumentException("message object is required");
|
||||
}
|
||||
String taskId = firstText(message.get("taskId"), params.get("id"));
|
||||
if (taskId.isBlank()) {
|
||||
taskId = "task-" + UUID.randomUUID();
|
||||
}
|
||||
String contextId = firstText(message.get("contextId"), params.get("contextId"));
|
||||
if (contextId.isBlank()) {
|
||||
contextId = taskId;
|
||||
}
|
||||
String text = extractText(message.get("parts"));
|
||||
if (text.isBlank()) {
|
||||
throw new IllegalArgumentException("message text is required");
|
||||
}
|
||||
Long agentId = agentId(message.get("metadata"));
|
||||
Long workspaceId = longOrDefault(params.get("workspaceId"), 1L);
|
||||
Long userId = auth.getDetails() instanceof Number n ? n.longValue() : null;
|
||||
return new A2aExecutionRequest(taskId, contextId, text, agentId, workspaceId, auth.getName(), userId);
|
||||
}
|
||||
|
||||
private static Long agentId(JsonNode metadata) {
|
||||
String skillId = metadata == null ? "" : text(metadata.get("skillId"));
|
||||
if (skillId.isBlank()) {
|
||||
throw new IllegalArgumentException("message.metadata.skillId is required");
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(skillId);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("message.metadata.skillId must be a numeric agent id");
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractText(JsonNode parts) {
|
||||
if (parts == null || !parts.isArray()) {
|
||||
return "";
|
||||
}
|
||||
List<String> texts = new ArrayList<>();
|
||||
for (JsonNode part : parts) {
|
||||
String text = text(part.get("text"));
|
||||
if (!text.isBlank()) {
|
||||
texts.add(text);
|
||||
}
|
||||
}
|
||||
return String.join("\n", texts);
|
||||
}
|
||||
|
||||
private static String taskId(JsonNode params) {
|
||||
String id = firstText(params == null ? null : params.get("id"),
|
||||
params == null ? null : params.get("taskId"));
|
||||
if (id.isBlank()) {
|
||||
throw new IllegalArgumentException("task id is required");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
private static String tenant(JsonNode params) {
|
||||
return text(params == null ? null : params.get("tenant"));
|
||||
}
|
||||
|
||||
private static Long longOrDefault(JsonNode node, Long fallback) {
|
||||
if (node == null || node.isNull()) {
|
||||
return fallback;
|
||||
}
|
||||
if (node.isNumber()) {
|
||||
return node.longValue();
|
||||
}
|
||||
if (node.isTextual() && !node.asText().isBlank()) {
|
||||
return Long.parseLong(node.asText());
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static Object rpcId(JsonNode id) {
|
||||
if (id == null || id.isNull()) {
|
||||
return null;
|
||||
}
|
||||
if (id.isTextual()) {
|
||||
return id.asText();
|
||||
}
|
||||
if (id.isNumber()) {
|
||||
return id.numberValue();
|
||||
}
|
||||
return InvalidRpcId.INSTANCE;
|
||||
}
|
||||
|
||||
private static String firstText(JsonNode first, JsonNode second) {
|
||||
String value = text(first);
|
||||
return value.isBlank() ? text(second) : value;
|
||||
}
|
||||
|
||||
private static String text(JsonNode node) {
|
||||
return node == null || node.isNull() ? "" : node.asText("");
|
||||
}
|
||||
|
||||
private static Map<String, Object> result(Object id, Object result) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("jsonrpc", "2.0");
|
||||
out.put("id", id);
|
||||
out.put("result", result);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Map<String, Object> error(Object id, int code, String message) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("jsonrpc", "2.0");
|
||||
out.put("id", id);
|
||||
out.put("error", Map.of("code", code, "message", message == null ? "" : message));
|
||||
return out;
|
||||
}
|
||||
|
||||
private void send(SseEmitter emitter, String event, Object data) throws IOException {
|
||||
emitter.send(SseEmitter.event()
|
||||
.name(event)
|
||||
.data(objectMapper.writeValueAsString(data)));
|
||||
}
|
||||
|
||||
private void heartbeat(SseEmitter emitter, AtomicBoolean done) {
|
||||
while (!done.get()) {
|
||||
try {
|
||||
Thread.sleep(15_000L);
|
||||
if (!done.get()) {
|
||||
emitter.send(SseEmitter.event().comment("heartbeat"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
done.set(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum InvalidRpcId {
|
||||
INSTANCE
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,298 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public class A2aPeerAdapter {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Policy policy;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
public A2aPeerAdapter(ObjectMapper objectMapper, Policy policy) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.policy = policy == null ? Policy.defaults() : policy;
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(this.policy.timeout())
|
||||
.followRedirects(HttpClient.Redirect.NEVER)
|
||||
.build();
|
||||
}
|
||||
|
||||
public PeerResult sendBlocking(String url, String message, String contextId, String skillId,
|
||||
Map<String, String> headers) throws IOException, InterruptedException {
|
||||
URI uri = resolveRpcUri(url, headers == null ? Map.of() : headers);
|
||||
Map<String, Object> body = rpcBody("message/send", message, contextId, skillId);
|
||||
CappedBody response = post(uri, body, headers == null ? Map.of() : headers);
|
||||
CappedBody finalBody = pollIfRunning(uri, response, headers == null ? Map.of() : headers);
|
||||
return new PeerResult(finalBody.body(), List.of(), response.truncated() || finalBody.truncated());
|
||||
}
|
||||
|
||||
public PeerResult stream(String url, String message, String contextId, String skillId,
|
||||
Map<String, String> headers) throws IOException, InterruptedException {
|
||||
URI uri = resolveRpcUri(url, headers == null ? Map.of() : headers);
|
||||
Map<String, Object> body = rpcBody("message/stream", message, contextId, skillId);
|
||||
CappedBody response = post(uri, body, headers == null ? Map.of() : headers);
|
||||
return new PeerResult(response.body(), SseFrames.parse(response.body()), response.truncated());
|
||||
}
|
||||
|
||||
private CappedBody pollIfRunning(URI rpcUri, CappedBody initial, Map<String, String> headers)
|
||||
throws IOException, InterruptedException {
|
||||
JsonNode task = taskNode(initial.body());
|
||||
String taskId = task == null ? "" : text(task.get("id"));
|
||||
if (taskId.isBlank() || isTerminalState(task)) {
|
||||
return initial;
|
||||
}
|
||||
long deadline = System.nanoTime() + policy.timeout().toNanos();
|
||||
CappedBody latest = initial;
|
||||
while (System.nanoTime() < deadline) {
|
||||
Thread.sleep(Math.min(1_000L, Math.max(100L, policy.timeout().toMillis())));
|
||||
latest = post(rpcUri, taskGetBody(taskId), headers);
|
||||
task = taskNode(latest.body());
|
||||
if (task == null || isTerminalState(task)) {
|
||||
return latest;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
private Map<String, Object> taskGetBody(String taskId) {
|
||||
return Map.of(
|
||||
"jsonrpc", "2.0",
|
||||
"id", "rpc-" + UUID.randomUUID(),
|
||||
"method", "tasks/get",
|
||||
"params", Map.of("id", taskId)
|
||||
);
|
||||
}
|
||||
|
||||
private JsonNode taskNode(String body) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(body);
|
||||
JsonNode result = root.get("result");
|
||||
if (result == null || result.isNull()) {
|
||||
return null;
|
||||
}
|
||||
if (result.has("task")) {
|
||||
return result.get("task");
|
||||
}
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isTerminalState(JsonNode task) {
|
||||
JsonNode status = task.get("status");
|
||||
String state = status == null ? "" : text(status.get("state"));
|
||||
return "completed".equalsIgnoreCase(state)
|
||||
|| "canceled".equalsIgnoreCase(state)
|
||||
|| "cancelled".equalsIgnoreCase(state)
|
||||
|| "failed".equalsIgnoreCase(state)
|
||||
|| "TASK_STATE_COMPLETED".equals(state)
|
||||
|| "TASK_STATE_CANCELED".equals(state)
|
||||
|| "TASK_STATE_FAILED".equals(state);
|
||||
}
|
||||
|
||||
private URI resolveRpcUri(String endpoint, Map<String, String> headers) {
|
||||
URI configured = safeUri(endpoint);
|
||||
for (URI candidate : cardCandidates(configured)) {
|
||||
try {
|
||||
CappedBody card = get(candidate, headers);
|
||||
String url = rpcUrlFromCard(card.body());
|
||||
if (!url.isBlank()) {
|
||||
return safeUri(url);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Discovery is best-effort; the configured endpoint remains valid.
|
||||
}
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
private List<URI> cardCandidates(URI endpoint) {
|
||||
List<URI> out = new ArrayList<>();
|
||||
String raw = endpoint.toString();
|
||||
if (raw.endsWith(".json")) {
|
||||
out.add(endpoint);
|
||||
}
|
||||
out.add(endpoint.resolve(trimTrailingSlash(endpoint.getPath()) + "/card"));
|
||||
out.add(endpoint.resolve("/.well-known/agent-card.json"));
|
||||
return out;
|
||||
}
|
||||
|
||||
private String rpcUrlFromCard(String body) throws IOException {
|
||||
JsonNode root = objectMapper.readTree(body);
|
||||
JsonNode interfaces = root.get("supportedInterfaces");
|
||||
if (interfaces != null && interfaces.isArray()) {
|
||||
for (JsonNode iface : interfaces) {
|
||||
if ("JSONRPC".equalsIgnoreCase(text(iface.get("protocolBinding")))) {
|
||||
String url = text(iface.get("url"));
|
||||
if (!url.isBlank()) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return text(root.get("url"));
|
||||
}
|
||||
|
||||
private CappedBody get(URI uri, Map<String, String> headers) throws IOException, InterruptedException {
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(safeUri(uri.toString()))
|
||||
.timeout(policy.timeout())
|
||||
.GET();
|
||||
for (Map.Entry<String, String> header : headers.entrySet()) {
|
||||
if (header.getKey() != null && header.getValue() != null) {
|
||||
builder.header(header.getKey(), header.getValue());
|
||||
}
|
||||
}
|
||||
HttpResponse<byte[]> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (response.statusCode() >= 300 && response.statusCode() < 400) {
|
||||
throw new IOException("redirects are not allowed");
|
||||
}
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new IOException("peer returned HTTP " + response.statusCode());
|
||||
}
|
||||
return cap(response.body());
|
||||
}
|
||||
|
||||
private Map<String, Object> rpcBody(String method, String message, String contextId, String skillId) {
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
if (skillId != null && !skillId.isBlank()) {
|
||||
metadata.put("skillId", skillId);
|
||||
}
|
||||
Map<String, Object> msg = new LinkedHashMap<>();
|
||||
msg.put("messageId", "msg-" + UUID.randomUUID());
|
||||
if (contextId != null && !contextId.isBlank()) {
|
||||
msg.put("contextId", contextId);
|
||||
}
|
||||
msg.put("parts", List.of(Map.of("kind", "text", "text", message == null ? "" : message)));
|
||||
msg.put("metadata", metadata);
|
||||
return Map.of(
|
||||
"jsonrpc", "2.0",
|
||||
"id", "rpc-" + UUID.randomUUID(),
|
||||
"method", method,
|
||||
"params", Map.of("message", msg, "configuration", Map.of("blocking", true))
|
||||
);
|
||||
}
|
||||
|
||||
private CappedBody post(URI uri, Map<String, Object> body, Map<String, String> headers)
|
||||
throws IOException, InterruptedException {
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(uri)
|
||||
.timeout(policy.timeout())
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)));
|
||||
for (Map.Entry<String, String> header : headers.entrySet()) {
|
||||
if (header.getKey() != null && header.getValue() != null) {
|
||||
builder.header(header.getKey(), header.getValue());
|
||||
}
|
||||
}
|
||||
HttpResponse<byte[]> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (response.statusCode() >= 300 && response.statusCode() < 400) {
|
||||
throw new IOException("redirects are not allowed");
|
||||
}
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new IOException("peer returned HTTP " + response.statusCode());
|
||||
}
|
||||
return cap(response.body());
|
||||
}
|
||||
|
||||
private CappedBody cap(byte[] body) {
|
||||
byte[] bytes = body == null ? new byte[0] : body;
|
||||
boolean truncated = bytes.length > policy.maxResponseBytes();
|
||||
int length = truncated ? policy.maxResponseBytes() : bytes.length;
|
||||
return new CappedBody(new String(bytes, 0, length, StandardCharsets.UTF_8), truncated);
|
||||
}
|
||||
|
||||
private URI safeUri(String url) {
|
||||
URI uri = URI.create(url);
|
||||
String scheme = uri.getScheme();
|
||||
if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
|
||||
throw new IllegalArgumentException("Only HTTP and HTTPS A2A URLs are supported");
|
||||
}
|
||||
if (uri.getHost() == null || uri.getHost().isBlank()) {
|
||||
throw new IllegalArgumentException("A2A URL host is required");
|
||||
}
|
||||
if (!policy.allowPrivateNetwork()) {
|
||||
rejectPrivateAddress(uri.getHost());
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
private static void rejectPrivateAddress(String host) {
|
||||
try {
|
||||
for (InetAddress address : InetAddress.getAllByName(host)) {
|
||||
byte[] raw = address.getAddress();
|
||||
if (address.isAnyLocalAddress()
|
||||
|| address.isLoopbackAddress()
|
||||
|| address.isLinkLocalAddress()
|
||||
|| address.isSiteLocalAddress()
|
||||
|| isReserved(raw)) {
|
||||
throw new IllegalArgumentException("A2A URL resolves to a private or reserved address");
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("A2A URL host could not be resolved", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isReserved(byte[] raw) {
|
||||
if (raw.length == 4) {
|
||||
int first = raw[0] & 0xff;
|
||||
int second = raw[1] & 0xff;
|
||||
return first == 0
|
||||
|| first == 10
|
||||
|| first == 127
|
||||
|| first == 169 && second == 254
|
||||
|| first == 172 && second >= 16 && second <= 31
|
||||
|| first == 192 && second == 168
|
||||
|| first >= 224;
|
||||
}
|
||||
if (raw.length == 16) {
|
||||
int first = raw[0] & 0xff;
|
||||
return first == 0
|
||||
|| first == 0xfc
|
||||
|| first == 0xfd
|
||||
|| first == 0xfe;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
return path.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
private static String text(JsonNode node) {
|
||||
return node == null || node.isNull() ? "" : node.asText("");
|
||||
}
|
||||
|
||||
public record Policy(Duration timeout, int maxResponseBytes, boolean allowPrivateNetwork) {
|
||||
public static Policy defaults() {
|
||||
return new Policy(Duration.ofSeconds(120), 1_048_576, false);
|
||||
}
|
||||
}
|
||||
|
||||
public record PeerResult(String body, List<SseFrames.Frame> frames, boolean truncated) {
|
||||
public PeerResult {
|
||||
frames = frames == null ? List.of() : List.copyOf(frames);
|
||||
}
|
||||
}
|
||||
|
||||
private record CappedBody(String body, boolean truncated) {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Configuration
|
||||
public class A2aPeerAdapterConfiguration {
|
||||
|
||||
@Bean
|
||||
public A2aPeerAdapter a2aPeerAdapter(ObjectMapper objectMapper, A2aProperties properties) {
|
||||
A2aPeerAdapter.Policy policy = new A2aPeerAdapter.Policy(
|
||||
Duration.ofMillis(properties.getOutboundTimeoutMs()),
|
||||
properties.getMaxResponseBytes(),
|
||||
properties.isAllowPrivateOutbound()
|
||||
);
|
||||
return new A2aPeerAdapter(objectMapper, policy);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "mateclaw.a2a")
|
||||
public class A2aProperties {
|
||||
|
||||
private boolean enabled = false;
|
||||
|
||||
private String baseUrl;
|
||||
|
||||
private long callTimeoutMs = 120_000L;
|
||||
|
||||
private int maxTasks = 1_000;
|
||||
|
||||
private long taskTtlSeconds = 3_600L;
|
||||
|
||||
private int maxResponseBytes = 1_048_576;
|
||||
|
||||
private long outboundTimeoutMs = 120_000L;
|
||||
|
||||
private boolean allowPrivateOutbound = false;
|
||||
|
||||
private long sweepIntervalMs = 60_000L;
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public record A2aTask(
|
||||
String id,
|
||||
String contextId,
|
||||
String tenant,
|
||||
String state,
|
||||
String message,
|
||||
List<Map<String, Object>> artifacts,
|
||||
boolean terminal,
|
||||
Instant createdAt,
|
||||
Instant updatedAt
|
||||
) {
|
||||
|
||||
private static final List<String> TERMINAL_STATES = List.of("completed", "canceled", "failed");
|
||||
|
||||
public A2aTask {
|
||||
artifacts = artifacts == null ? List.of() : List.copyOf(artifacts);
|
||||
createdAt = createdAt == null ? Instant.now() : createdAt;
|
||||
updatedAt = updatedAt == null ? createdAt : updatedAt;
|
||||
}
|
||||
|
||||
public static A2aTask submitted(String id, String contextId, String tenant) {
|
||||
Instant now = Instant.now();
|
||||
return new A2aTask(id, contextId, tenant, "submitted", null, List.of(), false, now, now);
|
||||
}
|
||||
|
||||
public A2aTask withStatus(String state, String message, boolean terminal) {
|
||||
boolean finalState = terminal || TERMINAL_STATES.contains(state);
|
||||
return new A2aTask(id, contextId, tenant, state, message, artifacts, finalState, createdAt, Instant.now());
|
||||
}
|
||||
|
||||
public A2aTask withUpdatedAt(Instant updatedAt) {
|
||||
return new A2aTask(id, contextId, tenant, state, message, artifacts, terminal, createdAt, updatedAt);
|
||||
}
|
||||
|
||||
public A2aTask withArtifact(String text, boolean append) {
|
||||
Map<String, Object> artifact = Map.of(
|
||||
"artifactId", "artifact-" + (artifacts.size() + 1),
|
||||
"parts", List.of(Map.of("kind", "text", "text", text != null ? text : ""))
|
||||
);
|
||||
List<Map<String, Object>> next = append
|
||||
? new ArrayList<>(artifacts)
|
||||
: new ArrayList<>();
|
||||
next.add(artifact);
|
||||
return new A2aTask(id, contextId, tenant, state, message, next, terminal, createdAt, Instant.now());
|
||||
}
|
||||
|
||||
public Map<String, Object> toMap() {
|
||||
return Map.of(
|
||||
"id", id,
|
||||
"contextId", contextId,
|
||||
"status", Map.of(
|
||||
"state", state,
|
||||
"message", messageAsA2aMessage(message)
|
||||
),
|
||||
"artifacts", artifacts
|
||||
);
|
||||
}
|
||||
|
||||
private static Object messageAsA2aMessage(String text) {
|
||||
if (text == null || text.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
return Map.of(
|
||||
"role", "agent",
|
||||
"parts", List.of(Map.of("kind", "text", "text", text))
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
public class A2aTaskStore {
|
||||
|
||||
private final int maxTasks;
|
||||
private final Duration ttl;
|
||||
private final Clock clock;
|
||||
private final ConcurrentMap<String, A2aTask> tasks = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<String, Map<String, Object>> rpcSnapshots = new ConcurrentHashMap<>();
|
||||
|
||||
public A2aTaskStore(int maxTasks, Duration ttl) {
|
||||
this(maxTasks, ttl, Clock.systemUTC());
|
||||
}
|
||||
|
||||
public A2aTaskStore(int maxTasks, Duration ttl, Clock clock) {
|
||||
this.maxTasks = Math.max(1, maxTasks);
|
||||
this.ttl = ttl == null ? Duration.ofHours(1) : ttl;
|
||||
this.clock = clock == null ? Clock.systemUTC() : clock;
|
||||
}
|
||||
|
||||
public boolean putIfAbsent(String tenant, A2aTask task) {
|
||||
if (task == null || task.id() == null || task.id().isBlank()) {
|
||||
return false;
|
||||
}
|
||||
if (tasks.size() >= maxTasks) {
|
||||
sweepExpired();
|
||||
if (tasks.size() >= maxTasks) {
|
||||
throw new IllegalStateException("too many A2A tasks");
|
||||
}
|
||||
}
|
||||
return tasks.putIfAbsent(taskKey(tenant, task.id()), task.withUpdatedAt(clock.instant())) == null;
|
||||
}
|
||||
|
||||
public Optional<A2aTask> get(String tenant, String taskId) {
|
||||
if (taskId == null || taskId.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(tasks.get(taskKey(tenant, taskId)));
|
||||
}
|
||||
|
||||
public Optional<A2aTask> update(String tenant, String taskId, UnaryOperator<A2aTask> updater) {
|
||||
String key = taskKey(tenant, taskId);
|
||||
A2aTask updated = tasks.computeIfPresent(key,
|
||||
(ignored, current) -> updater.apply(current).withUpdatedAt(clock.instant()));
|
||||
return Optional.ofNullable(updated);
|
||||
}
|
||||
|
||||
public boolean rememberRpcSnapshot(String tenant, String rpcId, Map<String, Object> snapshot) {
|
||||
if (rpcId == null || snapshot == null) {
|
||||
return true;
|
||||
}
|
||||
return rpcSnapshots.putIfAbsent(rpcKey(tenant, rpcId), Map.copyOf(snapshot)) == null;
|
||||
}
|
||||
|
||||
public Optional<Map<String, Object>> rpcSnapshot(String tenant, String rpcId) {
|
||||
if (rpcId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(rpcSnapshots.get(rpcKey(tenant, rpcId)));
|
||||
}
|
||||
|
||||
public int sweepExpired() {
|
||||
Instant cutoff = clock.instant().minus(ttl);
|
||||
int removed = 0;
|
||||
Iterator<Map.Entry<String, A2aTask>> it = tasks.entrySet().iterator();
|
||||
while (it.hasNext()) {
|
||||
Map.Entry<String, A2aTask> entry = it.next();
|
||||
A2aTask task = entry.getValue();
|
||||
if (task.terminal() && task.updatedAt().isBefore(cutoff)) {
|
||||
it.remove();
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
private static String taskKey(String tenant, String taskId) {
|
||||
return normalizeTenant(tenant) + "|" + taskId;
|
||||
}
|
||||
|
||||
private static String rpcKey(String tenant, String rpcId) {
|
||||
return normalizeTenant(tenant) + "|rpc|" + rpcId;
|
||||
}
|
||||
|
||||
private static String normalizeTenant(String tenant) {
|
||||
return tenant == null || tenant.isBlank() ? "default" : tenant;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Configuration
|
||||
public class A2aTaskStoreConfiguration {
|
||||
|
||||
@Bean
|
||||
public A2aTaskStore a2aTaskStore(A2aProperties properties) {
|
||||
return new A2aTaskStore(properties.getMaxTasks(), Duration.ofSeconds(properties.getTaskTtlSeconds()));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class A2aTaskSweeper {
|
||||
|
||||
private final A2aProperties properties;
|
||||
private final A2aTaskStore store;
|
||||
|
||||
@Scheduled(fixedDelayString = "${mateclaw.a2a.sweep-interval-ms:60000}")
|
||||
public void sweep() {
|
||||
if (!properties.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
int removed = store.sweepExpired();
|
||||
if (removed > 0) {
|
||||
log.debug("A2A task sweep removed {} expired task(s)", removed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DefaultA2aExecutionBridge implements A2aExecutionBridge {
|
||||
|
||||
private final AgentService agentService;
|
||||
|
||||
@Override
|
||||
public ExecutionResult executeBlocking(A2aExecutionRequest request) {
|
||||
ChatOrigin origin = ChatOrigin.web(
|
||||
request.contextId(),
|
||||
request.username(),
|
||||
request.workspaceId(),
|
||||
null,
|
||||
null,
|
||||
request.userId()
|
||||
);
|
||||
AgentService.ChatResult result = agentService.chatWithUsage(
|
||||
request.agentId(),
|
||||
request.message(),
|
||||
request.contextId(),
|
||||
origin
|
||||
);
|
||||
return new ExecutionResult(result.content(), true);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class SseFrames {
|
||||
|
||||
private SseFrames() {
|
||||
}
|
||||
|
||||
public record Frame(String event, String data) {
|
||||
}
|
||||
|
||||
public static List<Frame> parse(String input) {
|
||||
List<Frame> frames = new ArrayList<>();
|
||||
if (input == null || input.isEmpty()) {
|
||||
return frames;
|
||||
}
|
||||
String event = "message";
|
||||
List<String> dataLines = new ArrayList<>();
|
||||
String[] lines = input.split("\\R", -1);
|
||||
for (String line : lines) {
|
||||
if (line.isEmpty()) {
|
||||
flush(frames, event, dataLines);
|
||||
event = "message";
|
||||
dataLines = new ArrayList<>();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith(":")) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("event:")) {
|
||||
event = line.substring("event:".length()).trim();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("data:")) {
|
||||
String value = line.substring("data:".length());
|
||||
dataLines.add(value.startsWith(" ") ? value.substring(1) : value);
|
||||
}
|
||||
}
|
||||
flush(frames, event, dataLines);
|
||||
return frames;
|
||||
}
|
||||
|
||||
private static void flush(List<Frame> frames, String event, List<String> dataLines) {
|
||||
if (!dataLines.isEmpty()) {
|
||||
frames.add(new Frame(event == null || event.isBlank() ? "message" : event,
|
||||
String.join("\n", dataLines)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -131,6 +131,19 @@ springdoc:
|
||||
|
||||
# MateClaw 自定义配置
|
||||
mateclaw:
|
||||
a2a:
|
||||
enabled: ${MATECLAW_A2A_ENABLED:false}
|
||||
# Public base URL for Agent Cards. Production deployments should set this
|
||||
# explicitly so peers do not depend on proxy-derived request headers.
|
||||
base-url: ${MATECLAW_A2A_BASE_URL:}
|
||||
call-timeout-ms: ${MATECLAW_A2A_CALL_TIMEOUT_MS:120000}
|
||||
max-tasks: ${MATECLAW_A2A_MAX_TASKS:1000}
|
||||
task-ttl-seconds: ${MATECLAW_A2A_TASK_TTL_SECONDS:3600}
|
||||
max-response-bytes: ${MATECLAW_A2A_MAX_RESPONSE_BYTES:1048576}
|
||||
outbound-timeout-ms: ${MATECLAW_A2A_OUTBOUND_TIMEOUT_MS:120000}
|
||||
allow-private-outbound: ${MATECLAW_A2A_ALLOW_PRIVATE_OUTBOUND:false}
|
||||
sweep-interval-ms: ${MATECLAW_A2A_SWEEP_INTERVAL_MS:60000}
|
||||
|
||||
# DeepSeek Harness runtime. The executable and Cordis composition are kept
|
||||
# outside the Spring classpath and can be supplied by environment variables.
|
||||
agent:
|
||||
|
||||
39
mateclaw-server/src/main/resources/docs/en/a2a.md
Normal file
39
mateclaw-server/src/main/resources/docs/en/a2a.md
Normal file
@ -0,0 +1,39 @@
|
||||
# A2A Protocol
|
||||
|
||||
MateClaw can expose enabled agents through an A2A JSON-RPC endpoint and can call other A2A peers from agent tools.
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
mateclaw:
|
||||
a2a:
|
||||
enabled: true
|
||||
base-url: https://your-public-host
|
||||
call-timeout-ms: 120000
|
||||
max-tasks: 1000
|
||||
task-ttl-seconds: 3600
|
||||
```
|
||||
|
||||
`base-url` is required for production deployments. If it is empty, MateClaw derives the Agent Card URL from the incoming request, which depends on proxy headers being correct.
|
||||
|
||||
## Inbound
|
||||
|
||||
- `GET /.well-known/agent-card.json` and anonymous `GET /api/a2a/card` return the public minimal card without `skills`.
|
||||
- Authenticated `GET /api/a2a/card` returns enabled agents in `skills[]`; use the agent id as `message.metadata.skillId`.
|
||||
- `POST /api/a2a` requires an existing MateClaw Bearer token and supports `message/send`, `message/stream`, `tasks/get`, and `tasks/cancel`.
|
||||
|
||||
## Outbound Tool
|
||||
|
||||
Agents can call `call_a2a_agent` with:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://peer.example.com/api/a2a",
|
||||
"headers": {
|
||||
"Authorization": "Bearer token"
|
||||
},
|
||||
"stream": false
|
||||
}
|
||||
```
|
||||
|
||||
Outbound calls reject private or reserved network targets by default, do not follow redirects, cap response size, and poll `tasks/get` when a blocking send returns an in-progress task.
|
||||
39
mateclaw-server/src/main/resources/docs/zh/a2a.md
Normal file
39
mateclaw-server/src/main/resources/docs/zh/a2a.md
Normal file
@ -0,0 +1,39 @@
|
||||
# A2A 协议
|
||||
|
||||
MateClaw 可以把已启用智能体暴露为 A2A JSON-RPC 端点,也可以通过工具调用其他 A2A 对端。
|
||||
|
||||
## 配置
|
||||
|
||||
```yaml
|
||||
mateclaw:
|
||||
a2a:
|
||||
enabled: true
|
||||
base-url: https://your-public-host
|
||||
call-timeout-ms: 120000
|
||||
max-tasks: 1000
|
||||
task-ttl-seconds: 3600
|
||||
```
|
||||
|
||||
生产环境必须配置 `base-url`。为空时,MateClaw 会按入站请求推导名片 URL,这依赖反向代理正确传递 Host 与协议头。
|
||||
|
||||
## 入站
|
||||
|
||||
- `GET /.well-known/agent-card.json` 和匿名 `GET /api/a2a/card` 返回最小公开名片,不包含 `skills`。
|
||||
- 带 Bearer 访问 `GET /api/a2a/card` 返回 enabled 智能体列表到 `skills[]`;调用时把智能体 id 放到 `message.metadata.skillId`。
|
||||
- `POST /api/a2a` 复用 MateClaw Bearer token 鉴权,支持 `message/send`、`message/stream`、`tasks/get`、`tasks/cancel`。
|
||||
|
||||
## 出站工具
|
||||
|
||||
智能体可调用 `call_a2a_agent`:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://peer.example.com/api/a2a",
|
||||
"headers": {
|
||||
"Authorization": "Bearer token"
|
||||
},
|
||||
"stream": false
|
||||
}
|
||||
```
|
||||
|
||||
出站默认拒绝内网和保留地址,不跟随重定向,限制响应大小;阻塞发送返回在途任务时,会继续轮询 `tasks/get`。
|
||||
@ -0,0 +1,142 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class A2aJsonRpcControllerTest {
|
||||
|
||||
private MockMvc mvc;
|
||||
private A2aExecutionBridge bridge;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
bridge = mock(A2aExecutionBridge.class);
|
||||
A2aTaskStore store = new A2aTaskStore(100, Duration.ofMinutes(5));
|
||||
A2aProperties properties = new A2aProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setCallTimeoutMs(1000);
|
||||
A2aJsonRpcController rpc = new A2aJsonRpcController(objectMapper, properties, store, bridge);
|
||||
A2aAgentCardService cardService = mock(A2aAgentCardService.class);
|
||||
when(cardService.publicCard(any())).thenReturn(Map.of(
|
||||
"name", "MateClaw",
|
||||
"supportsAuthenticatedExtendedCard", true));
|
||||
when(cardService.authenticatedCard(any(), any())).thenReturn(Map.of(
|
||||
"name", "MateClaw",
|
||||
"skills", List.of(Map.of("id", "agent-1", "name", "Agent"))));
|
||||
A2aAgentCardController card = new A2aAgentCardController(cardService);
|
||||
mvc = MockMvcBuilders.standaloneSetup(rpc, card).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBooleanJsonRpcId() throws Exception {
|
||||
mvc.perform(post("/api/a2a")
|
||||
.contentType("application/json")
|
||||
.principal(auth())
|
||||
.content("""
|
||||
{"jsonrpc":"2.0","id":true,"method":"tasks/get","params":{"id":"task-1"}}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.error.code").value(-32600));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousCardOmitsSkillsAndAdvertisesExtendedCard() throws Exception {
|
||||
mvc.perform(get("/api/a2a/card"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.supportsAuthenticatedExtendedCard").value(true))
|
||||
.andExpect(jsonPath("$.skills").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatedCardIncludesSkills() throws Exception {
|
||||
mvc.perform(get("/api/a2a/card").principal(auth()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.skills[0].id").value("agent-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendCreatesTaskAndDuplicateRpcIdReturnsSameSnapshot() throws Exception {
|
||||
when(bridge.executeBlocking(any())).thenReturn(new A2aExecutionBridge.ExecutionResult("hello", true));
|
||||
String body = """
|
||||
{"jsonrpc":"2.0","id":"rpc-1","method":"message/send","params":{
|
||||
"message":{"messageId":"m1","taskId":"task-1","parts":[{"kind":"text","text":"hi"}],
|
||||
"metadata":{"skillId":"1"}},
|
||||
"configuration":{"blocking":true}
|
||||
}}
|
||||
""";
|
||||
|
||||
mvc.perform(post("/api/a2a").contentType("application/json").principal(auth()).content(body))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.result.id").value("task-1"))
|
||||
.andExpect(jsonPath("$.result.status.state").value("completed"));
|
||||
|
||||
mvc.perform(post("/api/a2a").contentType("application/json").principal(auth()).content(body))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.result.id").value("task-1"))
|
||||
.andExpect(jsonPath("$.result.status.state").value("completed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateTaskIdWithDifferentRpcIdIsRejected() throws Exception {
|
||||
when(bridge.executeBlocking(any())).thenReturn(new A2aExecutionBridge.ExecutionResult("hello", true));
|
||||
String first = """
|
||||
{"jsonrpc":"2.0","id":"rpc-1","method":"message/send","params":{
|
||||
"message":{"messageId":"m1","taskId":"task-1","parts":[{"kind":"text","text":"hi"}],
|
||||
"metadata":{"skillId":"1"}}
|
||||
}}
|
||||
""";
|
||||
String second = first.replace("\"rpc-1\"", "\"rpc-2\"");
|
||||
|
||||
mvc.perform(post("/api/a2a").contentType("application/json").principal(auth()).content(first))
|
||||
.andExpect(status().isOk());
|
||||
mvc.perform(post("/api/a2a").contentType("application/json").principal(auth()).content(second))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.error.code").value(-32009));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelTransitionsActiveTaskToCanceled() throws Exception {
|
||||
when(bridge.executeBlocking(any())).thenReturn(new A2aExecutionBridge.ExecutionResult("hello", false));
|
||||
String send = """
|
||||
{"jsonrpc":"2.0","id":"rpc-1","method":"message/send","params":{
|
||||
"message":{"messageId":"m1","taskId":"task-1","parts":[{"kind":"text","text":"hi"}],
|
||||
"metadata":{"skillId":"1"}},
|
||||
"configuration":{"blocking":true}
|
||||
}}
|
||||
""";
|
||||
mvc.perform(post("/api/a2a").contentType("application/json").principal(auth()).content(send))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.result.status.state", not("completed")));
|
||||
|
||||
mvc.perform(post("/api/a2a").contentType("application/json").principal(auth())
|
||||
.content("""
|
||||
{"jsonrpc":"2.0","id":"rpc-2","method":"tasks/cancel","params":{"id":"task-1"}}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.result.status.state").value("canceled"));
|
||||
}
|
||||
|
||||
private static UsernamePasswordAuthenticationToken auth() {
|
||||
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken("alice", null, List.of());
|
||||
token.setDetails(1L);
|
||||
return token;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class A2aPeerAdapterTest {
|
||||
|
||||
private HttpServer server;
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (server != null) {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void blocksPrivateNetworkTargetsByDefault() {
|
||||
A2aPeerAdapter adapter = new A2aPeerAdapter(new ObjectMapper(), A2aPeerAdapter.Policy.defaults());
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
adapter.sendBlocking("http://127.0.0.1:8642/api/a2a", "hi", null, null, Map.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesRedirects() throws Exception {
|
||||
int port = startServer(exchange -> {
|
||||
exchange.getResponseHeaders().add("Location", "https://example.com/api/a2a");
|
||||
exchange.sendResponseHeaders(302, -1);
|
||||
exchange.close();
|
||||
});
|
||||
A2aPeerAdapter adapter = new A2aPeerAdapter(new ObjectMapper(),
|
||||
new A2aPeerAdapter.Policy(Duration.ofSeconds(2), 1024 * 1024, true));
|
||||
|
||||
assertThrows(IOException.class, () ->
|
||||
adapter.sendBlocking("http://127.0.0.1:" + port + "/api/a2a", "hi", null, null, Map.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesSseFramesUsingEventBoundaries() throws Exception {
|
||||
int port = startServer(exchange -> {
|
||||
byte[] body = """
|
||||
event: artifact-update
|
||||
data: hello
|
||||
data: world
|
||||
|
||||
""".getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "text/event-stream");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
});
|
||||
A2aPeerAdapter adapter = new A2aPeerAdapter(new ObjectMapper(),
|
||||
new A2aPeerAdapter.Policy(Duration.ofSeconds(2), 1024 * 1024, true));
|
||||
|
||||
A2aPeerAdapter.PeerResult result = adapter.stream(
|
||||
"http://127.0.0.1:" + port + "/api/a2a", "hi", null, null, Map.of());
|
||||
|
||||
assertEquals(1, result.frames().size());
|
||||
assertEquals("hello\nworld", result.frames().getFirst().data());
|
||||
}
|
||||
|
||||
@Test
|
||||
void truncatesOversizedResponses() throws Exception {
|
||||
int port = startServer(exchange -> {
|
||||
byte[] body = "0123456789".getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
});
|
||||
A2aPeerAdapter adapter = new A2aPeerAdapter(new ObjectMapper(),
|
||||
new A2aPeerAdapter.Policy(Duration.ofSeconds(2), 5, true));
|
||||
|
||||
A2aPeerAdapter.PeerResult result = adapter.sendBlocking(
|
||||
"http://127.0.0.1:" + port + "/api/a2a", "hi", null, null, Map.of());
|
||||
|
||||
assertTrue(result.truncated());
|
||||
assertEquals(5, result.body().length());
|
||||
}
|
||||
|
||||
private int startServer(HttpHandler handler) throws Exception {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/api/a2a", handler);
|
||||
server.start();
|
||||
return server.getAddress().getPort();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class A2aTaskStoreTest {
|
||||
|
||||
@Test
|
||||
void putIfAbsentRejectsDuplicateTaskIdInSameTenant() {
|
||||
MutableClock clock = new MutableClock();
|
||||
A2aTaskStore store = new A2aTaskStore(10, Duration.ofMinutes(5), clock);
|
||||
A2aTask first = A2aTask.submitted("task-1", "ctx-1", "tenant-a");
|
||||
A2aTask duplicate = A2aTask.submitted("task-1", "ctx-2", "tenant-a");
|
||||
|
||||
assertTrue(store.putIfAbsent("tenant-a", first));
|
||||
assertFalse(store.putIfAbsent("tenant-a", duplicate));
|
||||
|
||||
assertEquals("ctx-1", store.get("tenant-a", "task-1").orElseThrow().contextId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateJsonRpcIdReturnsStoredSnapshot() {
|
||||
MutableClock clock = new MutableClock();
|
||||
A2aTaskStore store = new A2aTaskStore(10, Duration.ofMinutes(5), clock);
|
||||
Map<String, Object> snapshot = Map.of("taskId", "task-1", "status", "submitted");
|
||||
|
||||
assertTrue(store.rememberRpcSnapshot("tenant-a", "rpc-1", snapshot));
|
||||
assertFalse(store.rememberRpcSnapshot("tenant-a", "rpc-1", Map.of("taskId", "other")));
|
||||
|
||||
assertEquals(snapshot, store.rpcSnapshot("tenant-a", "rpc-1").orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sweepRemovesExpiredTerminalTasksAndKeepsActiveTasks() {
|
||||
MutableClock clock = new MutableClock();
|
||||
A2aTaskStore store = new A2aTaskStore(10, Duration.ofSeconds(30), clock);
|
||||
A2aTask done = A2aTask.submitted("done", "ctx", "tenant").withStatus("completed", "ok", true);
|
||||
A2aTask active = A2aTask.submitted("active", "ctx", "tenant").withStatus("working", null, false);
|
||||
|
||||
assertTrue(store.putIfAbsent("tenant", done));
|
||||
assertTrue(store.putIfAbsent("tenant", active));
|
||||
clock.advance(Duration.ofSeconds(31));
|
||||
|
||||
assertEquals(1, store.sweepExpired());
|
||||
assertTrue(store.get("tenant", "done").isEmpty());
|
||||
assertTrue(store.get("tenant", "active").isPresent());
|
||||
}
|
||||
|
||||
private static final class MutableClock extends Clock {
|
||||
private Instant now = Instant.parse("2026-08-19T00:00:00Z");
|
||||
|
||||
void advance(Duration duration) {
|
||||
now = now.plus(duration);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ZoneOffset getZone() {
|
||||
return ZoneOffset.UTC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(ZoneId zone) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return now;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class SseFramesTest {
|
||||
|
||||
@Test
|
||||
void parsesSingleDataFrameAtBlankLine() {
|
||||
List<SseFrames.Frame> frames = SseFrames.parse("event: artifact-update\ndata: {\"x\":1}\n\n");
|
||||
|
||||
assertEquals(1, frames.size());
|
||||
assertEquals("artifact-update", frames.getFirst().event());
|
||||
assertEquals("{\"x\":1}", frames.getFirst().data());
|
||||
}
|
||||
|
||||
@Test
|
||||
void joinsMultiLineDataWithNewlines() {
|
||||
List<SseFrames.Frame> frames = SseFrames.parse("event: message\ndata: first\ndata: second\n\n");
|
||||
|
||||
assertEquals("first\nsecond", frames.getFirst().data());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresCommentHeartbeatFrames() {
|
||||
List<SseFrames.Frame> frames = SseFrames.parse(": heartbeat\n\n");
|
||||
|
||||
assertTrue(frames.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void flushesTrailingFrameWithoutFinalBlankLine() {
|
||||
List<SseFrames.Frame> frames = SseFrames.parse("data: tail");
|
||||
|
||||
assertEquals(1, frames.size());
|
||||
assertEquals("message", frames.getFirst().event());
|
||||
assertEquals("tail", frames.getFirst().data());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesEventBoundaryAcrossMultipleFrames() {
|
||||
List<SseFrames.Frame> frames = SseFrames.parse("""
|
||||
event: status-update
|
||||
data: working
|
||||
|
||||
event: artifact-update
|
||||
data: one
|
||||
data: two
|
||||
|
||||
""");
|
||||
|
||||
assertEquals(2, frames.size());
|
||||
assertEquals("working", frames.get(0).data());
|
||||
assertEquals("one\ntwo", frames.get(1).data());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user