Skip to content

Commit e548a4f

Browse files
committed
feat(a2a): Add Agent Card compatibility checking (#6996)
Implements compatibility rules for A2A Agent Card artifacts to ensure safe evolution of agent capabilities. Breaking changes are detected for backward compatibility. Compatibility rules: - Adding skills/capabilities/modes: Always compatible - Removing skills: Backward incompatible, forward compatible - Removing/disabling capabilities: Backward incompatible - Changing URL: Incompatible (both directions) - Removing authentication schemes: Backward incompatible - Removing input/output modes: Backward incompatible Changes: - Add AgentCardCompatibilityChecker extending AbstractCompatibilityChecker - Add AgentCardCompatibilityDifference for detailed violation reporting - Update AgentCardArtifactTypeUtilProvider to use the new checker - Add comprehensive unit tests (13 test cases) Part of #6996
1 parent 132fc40 commit e548a4f

File tree

4 files changed

+681
-4
lines changed

4 files changed

+681
-4
lines changed
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package io.apicurio.registry.rules.compatibility;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import io.apicurio.registry.content.TypedContent;
6+
7+
import java.util.HashSet;
8+
import java.util.Iterator;
9+
import java.util.Map;
10+
import java.util.Set;
11+
12+
/**
13+
* Compatibility checker for A2A Agent Card artifacts.
14+
*
15+
* Compatibility rules for Agent Cards:
16+
* - Adding new skills: Always compatible
17+
* - Removing skills: Backward incompatible
18+
* - Adding capabilities: Always compatible
19+
* - Removing/disabling capabilities: Backward incompatible
20+
* - Changing URL: Incompatible (both directions)
21+
* - Adding authentication schemes: Always compatible
22+
* - Removing authentication schemes: Backward incompatible
23+
* - Adding input/output modes: Always compatible
24+
* - Removing input/output modes: Backward incompatible
25+
*/
26+
public class AgentCardCompatibilityChecker extends AbstractCompatibilityChecker<AgentCardCompatibilityDifference> {
27+
28+
private static final ObjectMapper mapper = new ObjectMapper();
29+
30+
@Override
31+
protected Set<AgentCardCompatibilityDifference> isBackwardsCompatibleWith(String existing, String proposed,
32+
Map<String, TypedContent> resolvedReferences) {
33+
Set<AgentCardCompatibilityDifference> differences = new HashSet<>();
34+
35+
try {
36+
JsonNode existingNode = mapper.readTree(existing);
37+
JsonNode proposedNode = mapper.readTree(proposed);
38+
39+
// Check URL changes (breaking in both directions)
40+
checkUrlCompatibility(existingNode, proposedNode, differences);
41+
42+
// Check removed skills
43+
checkSkillRemovals(existingNode, proposedNode, differences);
44+
45+
// Check removed or disabled capabilities
46+
checkCapabilityRemovals(existingNode, proposedNode, differences);
47+
48+
// Check removed authentication schemes
49+
checkAuthenticationRemovals(existingNode, proposedNode, differences);
50+
51+
// Check removed input modes
52+
checkModeRemovals(existingNode, proposedNode, "defaultInputModes", "input mode", differences);
53+
54+
// Check removed output modes
55+
checkModeRemovals(existingNode, proposedNode, "defaultOutputModes", "output mode", differences);
56+
57+
} catch (Exception e) {
58+
differences.add(new AgentCardCompatibilityDifference(
59+
AgentCardCompatibilityDifference.Type.PARSE_ERROR,
60+
"Failed to parse Agent Card: " + e.getMessage()));
61+
}
62+
63+
return differences;
64+
}
65+
66+
private void checkUrlCompatibility(JsonNode existing, JsonNode proposed,
67+
Set<AgentCardCompatibilityDifference> differences) {
68+
String existingUrl = getTextValue(existing, "url");
69+
String proposedUrl = getTextValue(proposed, "url");
70+
71+
if (existingUrl != null && proposedUrl != null && !existingUrl.equals(proposedUrl)) {
72+
differences.add(new AgentCardCompatibilityDifference(
73+
AgentCardCompatibilityDifference.Type.URL_CHANGED,
74+
"Agent URL changed from '" + existingUrl + "' to '" + proposedUrl + "'"));
75+
}
76+
}
77+
78+
private void checkSkillRemovals(JsonNode existing, JsonNode proposed,
79+
Set<AgentCardCompatibilityDifference> differences) {
80+
Set<String> existingSkills = extractSkillIds(existing);
81+
Set<String> proposedSkills = extractSkillIds(proposed);
82+
83+
for (String skillId : existingSkills) {
84+
if (!proposedSkills.contains(skillId)) {
85+
differences.add(new AgentCardCompatibilityDifference(
86+
AgentCardCompatibilityDifference.Type.SKILL_REMOVED,
87+
"Skill '" + skillId + "' was removed"));
88+
}
89+
}
90+
}
91+
92+
private void checkCapabilityRemovals(JsonNode existing, JsonNode proposed,
93+
Set<AgentCardCompatibilityDifference> differences) {
94+
JsonNode existingCaps = existing.get("capabilities");
95+
JsonNode proposedCaps = proposed.get("capabilities");
96+
97+
if (existingCaps == null || !existingCaps.isObject()) {
98+
return;
99+
}
100+
101+
Iterator<String> fieldNames = existingCaps.fieldNames();
102+
while (fieldNames.hasNext()) {
103+
String capName = fieldNames.next();
104+
boolean existingValue = existingCaps.get(capName).asBoolean(false);
105+
106+
if (existingValue) {
107+
// Check if capability was removed or disabled
108+
boolean proposedValue = false;
109+
if (proposedCaps != null && proposedCaps.has(capName)) {
110+
proposedValue = proposedCaps.get(capName).asBoolean(false);
111+
}
112+
113+
if (!proposedValue) {
114+
differences.add(new AgentCardCompatibilityDifference(
115+
AgentCardCompatibilityDifference.Type.CAPABILITY_REMOVED,
116+
"Capability '" + capName + "' was removed or disabled"));
117+
}
118+
}
119+
}
120+
}
121+
122+
private void checkAuthenticationRemovals(JsonNode existing, JsonNode proposed,
123+
Set<AgentCardCompatibilityDifference> differences) {
124+
Set<String> existingSchemes = extractAuthSchemes(existing);
125+
Set<String> proposedSchemes = extractAuthSchemes(proposed);
126+
127+
for (String scheme : existingSchemes) {
128+
if (!proposedSchemes.contains(scheme)) {
129+
differences.add(new AgentCardCompatibilityDifference(
130+
AgentCardCompatibilityDifference.Type.AUTH_SCHEME_REMOVED,
131+
"Authentication scheme '" + scheme + "' was removed"));
132+
}
133+
}
134+
}
135+
136+
private void checkModeRemovals(JsonNode existing, JsonNode proposed, String fieldName,
137+
String modeType, Set<AgentCardCompatibilityDifference> differences) {
138+
Set<String> existingModes = extractStringArray(existing, fieldName);
139+
Set<String> proposedModes = extractStringArray(proposed, fieldName);
140+
141+
for (String mode : existingModes) {
142+
if (!proposedModes.contains(mode)) {
143+
differences.add(new AgentCardCompatibilityDifference(
144+
AgentCardCompatibilityDifference.Type.MODE_REMOVED,
145+
"The " + modeType + " '" + mode + "' was removed"));
146+
}
147+
}
148+
}
149+
150+
private String getTextValue(JsonNode node, String fieldName) {
151+
JsonNode field = node.get(fieldName);
152+
return (field != null && field.isTextual()) ? field.asText() : null;
153+
}
154+
155+
private Set<String> extractSkillIds(JsonNode node) {
156+
Set<String> skills = new HashSet<>();
157+
JsonNode skillsNode = node.get("skills");
158+
if (skillsNode != null && skillsNode.isArray()) {
159+
for (JsonNode skill : skillsNode) {
160+
JsonNode idNode = skill.get("id");
161+
if (idNode != null && idNode.isTextual()) {
162+
skills.add(idNode.asText());
163+
}
164+
}
165+
}
166+
return skills;
167+
}
168+
169+
private Set<String> extractAuthSchemes(JsonNode node) {
170+
Set<String> schemes = new HashSet<>();
171+
JsonNode authNode = node.get("authentication");
172+
if (authNode != null && authNode.isObject()) {
173+
JsonNode schemesNode = authNode.get("schemes");
174+
if (schemesNode != null && schemesNode.isArray()) {
175+
for (JsonNode scheme : schemesNode) {
176+
if (scheme.isTextual()) {
177+
schemes.add(scheme.asText());
178+
}
179+
}
180+
}
181+
}
182+
return schemes;
183+
}
184+
185+
private Set<String> extractStringArray(JsonNode node, String fieldName) {
186+
Set<String> values = new HashSet<>();
187+
JsonNode arrayNode = node.get(fieldName);
188+
if (arrayNode != null && arrayNode.isArray()) {
189+
for (JsonNode item : arrayNode) {
190+
if (item.isTextual()) {
191+
values.add(item.asText());
192+
}
193+
}
194+
}
195+
return values;
196+
}
197+
198+
@Override
199+
protected CompatibilityDifference transform(AgentCardCompatibilityDifference original) {
200+
return original;
201+
}
202+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package io.apicurio.registry.rules.compatibility;
2+
3+
import io.apicurio.registry.rules.violation.RuleViolation;
4+
5+
import java.util.Objects;
6+
7+
/**
8+
* Represents a compatibility difference for A2A Agent Card artifacts.
9+
*/
10+
public class AgentCardCompatibilityDifference implements CompatibilityDifference {
11+
12+
public enum Type {
13+
URL_CHANGED("url"),
14+
SKILL_REMOVED("skills"),
15+
CAPABILITY_REMOVED("capabilities"),
16+
AUTH_SCHEME_REMOVED("authentication"),
17+
MODE_REMOVED("modes"),
18+
PARSE_ERROR("document");
19+
20+
private final String context;
21+
22+
Type(String context) {
23+
this.context = context;
24+
}
25+
26+
public String getContext() {
27+
return "/" + context;
28+
}
29+
}
30+
31+
private final Type type;
32+
private final String description;
33+
34+
public AgentCardCompatibilityDifference(Type type, String description) {
35+
this.type = Objects.requireNonNull(type);
36+
this.description = Objects.requireNonNull(description);
37+
}
38+
39+
public Type getType() {
40+
return type;
41+
}
42+
43+
public String getDescription() {
44+
return description;
45+
}
46+
47+
@Override
48+
public RuleViolation asRuleViolation() {
49+
return new RuleViolation(description, type.getContext());
50+
}
51+
52+
@Override
53+
public boolean equals(Object o) {
54+
if (this == o) return true;
55+
if (o == null || getClass() != o.getClass()) return false;
56+
AgentCardCompatibilityDifference that = (AgentCardCompatibilityDifference) o;
57+
return type == that.type && Objects.equals(description, that.description);
58+
}
59+
60+
@Override
61+
public int hashCode() {
62+
return Objects.hash(type, description);
63+
}
64+
65+
@Override
66+
public String toString() {
67+
return "AgentCardCompatibilityDifference{" +
68+
"type=" + type +
69+
", description='" + description + '\'' +
70+
'}';
71+
}
72+
}

schema-util/util-provider/src/main/java/io/apicurio/registry/types/provider/AgentCardArtifactTypeUtilProvider.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
import io.apicurio.registry.content.refs.NoOpReferenceFinder;
1313
import io.apicurio.registry.content.refs.ReferenceArtifactIdentifierExtractor;
1414
import io.apicurio.registry.content.refs.ReferenceFinder;
15+
import io.apicurio.registry.rules.compatibility.AgentCardCompatibilityChecker;
1516
import io.apicurio.registry.rules.compatibility.CompatibilityChecker;
16-
import io.apicurio.registry.rules.compatibility.NoopCompatibilityChecker;
1717
import io.apicurio.registry.rules.validity.AgentCardContentValidator;
1818
import io.apicurio.registry.rules.validity.ContentValidator;
1919
import io.apicurio.registry.types.ArtifactType;
@@ -48,9 +48,7 @@ protected ContentAccepter createContentAccepter() {
4848

4949
@Override
5050
protected CompatibilityChecker createCompatibilityChecker() {
51-
// For MVP, no compatibility checking
52-
// Future: implement Agent Card compatibility rules
53-
return NoopCompatibilityChecker.INSTANCE;
51+
return new AgentCardCompatibilityChecker();
5452
}
5553

5654
@Override

0 commit comments

Comments
 (0)