Skip to content

Commit 08986b3

Browse files
authored
Merge branch 'a2aproject:main' into fix-local-handling-race-condition
2 parents 4d746d4 + c12888d commit 08986b3

4 files changed

Lines changed: 276 additions & 39 deletions

File tree

client/base/src/main/java/io/a2a/client/ClientBuilder.java

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -328,15 +328,17 @@ private ClientTransport buildClientTransport() throws A2AClientException {
328328
return wrap(clientTransportProvider.create(clientTransportConfig, agentCard, agentInterface), clientTransportConfig);
329329
}
330330

331-
private Map<String, String> getServerPreferredTransports() throws A2AClientException {
332-
Map<String, String> serverPreferredTransports = new LinkedHashMap<>();
333-
if(agentCard.supportedInterfaces() == null || agentCard.supportedInterfaces().isEmpty()) {
331+
private Map<String, AgentInterface> getServerInterfacesMap() throws A2AClientException {
332+
List<AgentInterface> serverInterfaces = agentCard.supportedInterfaces();
333+
if (serverInterfaces == null || serverInterfaces.isEmpty()) {
334334
throw new A2AClientException("No server interface available in the AgentCard");
335335
}
336-
for (AgentInterface agentInterface : agentCard.supportedInterfaces()) {
337-
serverPreferredTransports.putIfAbsent(agentInterface.protocolBinding(), agentInterface.url());
336+
// If there are multiple interfaces with the same protocol binding, only the first is considered
337+
Map<String, AgentInterface> serverInterfacesMap = new LinkedHashMap<>();
338+
for (AgentInterface iface : serverInterfaces) {
339+
serverInterfacesMap.putIfAbsent(iface.protocolBinding(), iface);
338340
}
339-
return serverPreferredTransports;
341+
return serverInterfacesMap;
340342
}
341343

342344
private List<String> getClientPreferredTransports() {
@@ -351,40 +353,38 @@ private List<String> getClientPreferredTransports() {
351353
return supportedClientTransports;
352354
}
353355

354-
private AgentInterface findBestClientTransport() throws A2AClientException {
355-
// Retrieve transport supported by the A2A server
356-
Map<String, String> serverPreferredTransports = getServerPreferredTransports();
357-
358-
// Retrieve transport configured for this client (using withTransport methods)
356+
// Package-private for testing
357+
AgentInterface findBestClientTransport() throws A2AClientException {
358+
Map<String, AgentInterface> serverInterfacesMap = getServerInterfacesMap();
359359
List<String> clientPreferredTransports = getClientPreferredTransports();
360360

361-
String transportProtocol = null;
362-
String transportUrl = null;
361+
AgentInterface matchedInterface = null;
363362
if (clientConfig.isUseClientPreference()) {
363+
// Client preference: iterate client transports first, find first server match
364364
for (String clientPreferredTransport : clientPreferredTransports) {
365-
if (serverPreferredTransports.containsKey(clientPreferredTransport)) {
366-
transportProtocol = clientPreferredTransport;
367-
transportUrl = serverPreferredTransports.get(transportProtocol);
365+
if (serverInterfacesMap.containsKey(clientPreferredTransport)) {
366+
matchedInterface = serverInterfacesMap.get(clientPreferredTransport);
368367
break;
369368
}
370369
}
371370
} else {
372-
for (Map.Entry<String, String> transport : serverPreferredTransports.entrySet()) {
373-
if (clientPreferredTransports.contains(transport.getKey())) {
374-
transportProtocol = transport.getKey();
375-
transportUrl = transport.getValue();
371+
// Server preference: iterate server interfaces first, find first client match
372+
for (AgentInterface iface : serverInterfacesMap.values()) {
373+
if (clientPreferredTransports.contains(iface.protocolBinding())) {
374+
matchedInterface = iface;
376375
break;
377376
}
378377
}
379378
}
380-
if (transportProtocol == null || transportUrl == null) {
379+
380+
if (matchedInterface == null) {
381381
throw new A2AClientException("No compatible transport found");
382382
}
383-
if (!transportProviderRegistry.containsKey(transportProtocol)) {
384-
throw new A2AClientException("No client available for " + transportProtocol);
383+
if (!transportProviderRegistry.containsKey(matchedInterface.protocolBinding())) {
384+
throw new A2AClientException("No client available for " + matchedInterface.protocolBinding());
385385
}
386386

387-
return new AgentInterface(transportProtocol, transportUrl);
387+
return matchedInterface;
388388
}
389389

390390
/**

client/base/src/test/java/io/a2a/client/ClientBuilderTest.java

Lines changed: 96 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package io.a2a.client;
22

3-
43
import java.util.Collections;
54
import java.util.List;
65

@@ -22,27 +21,38 @@
2221

2322
public class ClientBuilderTest {
2423

25-
private AgentCard card = AgentCard.builder()
26-
.name("Hello World Agent")
24+
private static AgentCard buildCard(List<AgentInterface> interfaces) {
25+
return AgentCard.builder()
26+
.name("Hello World Agent")
2727
.description("Just a hello world agent")
2828
.version("1.0.0")
2929
.documentationUrl("http://example.com/docs")
3030
.capabilities(AgentCapabilities.builder()
3131
.streaming(true)
3232
.pushNotifications(true)
3333
.build())
34-
.defaultInputModes(Collections.singletonList("text"))
35-
.defaultOutputModes(Collections.singletonList("text"))
36-
.skills(Collections.singletonList(AgentSkill.builder()
37-
.id("hello_world")
38-
.name("Returns hello world")
39-
.description("just returns hello world")
40-
.tags(Collections.singletonList("hello world"))
41-
.examples(List.of("hi", "hello world"))
42-
.build()))
43-
.supportedInterfaces(List.of(
44-
new AgentInterface(TransportProtocol.JSONRPC.asString(), "http://localhost:9999")))
45-
.build();
34+
.defaultInputModes(Collections.singletonList("text"))
35+
.defaultOutputModes(Collections.singletonList("text"))
36+
.skills(Collections.singletonList(AgentSkill.builder()
37+
.id("hello_world")
38+
.name("Returns hello world")
39+
.description("just returns hello world")
40+
.tags(Collections.singletonList("hello world"))
41+
.examples(List.of("hi", "hello world"))
42+
.build()))
43+
.supportedInterfaces(interfaces)
44+
.build();
45+
}
46+
47+
private final AgentCard card = buildCard(List.of(
48+
new AgentInterface(TransportProtocol.JSONRPC.asString(), "http://localhost:9999")));
49+
50+
private final AgentCard cardWithTenant = buildCard(List.of(
51+
new AgentInterface(TransportProtocol.JSONRPC.asString(), "http://localhost:9999", "/default-tenant")));
52+
53+
private final AgentCard cardWithMultipleInterfaces = buildCard(List.of(
54+
new AgentInterface(TransportProtocol.GRPC.asString(), "http://localhost:9998", "/grpc-tenant", "1.0"),
55+
new AgentInterface(TransportProtocol.JSONRPC.asString(), "http://localhost:9999", "/jsonrpc-tenant", "1.0")));
4656

4757
@Test
4858
public void shouldNotFindCompatibleTransport() throws A2AClientException {
@@ -91,4 +101,75 @@ public void shouldCreateClient_differentConfigurations() throws A2AClientExcepti
91101

92102
Assertions.assertNotNull(client);
93103
}
104+
105+
@Test
106+
public void shouldPreserveTenantFromAgentInterface() throws A2AClientException {
107+
ClientBuilder builder = Client
108+
.builder(cardWithTenant)
109+
.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder());
110+
111+
AgentInterface selectedInterface = builder.findBestClientTransport();
112+
113+
Assertions.assertEquals("/default-tenant", selectedInterface.tenant());
114+
Assertions.assertEquals("http://localhost:9999", selectedInterface.url());
115+
Assertions.assertEquals(TransportProtocol.JSONRPC.asString(), selectedInterface.protocolBinding());
116+
}
117+
118+
@Test
119+
public void shouldPreserveProtocolVersionFromAgentInterface() throws A2AClientException {
120+
ClientBuilder builder = Client
121+
.builder(cardWithMultipleInterfaces)
122+
.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder());
123+
124+
AgentInterface selectedInterface = builder.findBestClientTransport();
125+
126+
Assertions.assertEquals("/jsonrpc-tenant", selectedInterface.tenant());
127+
Assertions.assertEquals("1.0", selectedInterface.protocolVersion());
128+
}
129+
130+
@Test
131+
public void shouldSelectCorrectInterfaceWithServerPreference() throws A2AClientException {
132+
// Server preference (default): iterates server interfaces in order, picks first that client supports
133+
// cardWithMultipleInterfaces has [GRPC, JSONRPC] - GRPC is first
134+
// Client supports both GRPC and JSONRPC, so GRPC should be selected (server's first choice)
135+
ClientBuilder builder = Client
136+
.builder(cardWithMultipleInterfaces)
137+
.withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder().channelFactory(s -> null))
138+
.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder());
139+
140+
AgentInterface selectedInterface = builder.findBestClientTransport();
141+
142+
Assertions.assertEquals(TransportProtocol.GRPC.asString(), selectedInterface.protocolBinding());
143+
Assertions.assertEquals("http://localhost:9998", selectedInterface.url());
144+
Assertions.assertEquals("/grpc-tenant", selectedInterface.tenant());
145+
}
146+
147+
@Test
148+
public void shouldSelectCorrectInterfaceWithClientPreference() throws A2AClientException {
149+
// Client preference: iterates client transports in registration order, picks first that server supports
150+
// Client registers [JSONRPC, GRPC] - JSONRPC is first
151+
// Server supports both, so JSONRPC should be selected (client's first choice)
152+
ClientBuilder builder = Client
153+
.builder(cardWithMultipleInterfaces)
154+
.clientConfig(new ClientConfig.Builder().setUseClientPreference(true).build())
155+
.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder())
156+
.withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder().channelFactory(s -> null));
157+
158+
AgentInterface selectedInterface = builder.findBestClientTransport();
159+
160+
Assertions.assertEquals(TransportProtocol.JSONRPC.asString(), selectedInterface.protocolBinding());
161+
Assertions.assertEquals("http://localhost:9999", selectedInterface.url());
162+
Assertions.assertEquals("/jsonrpc-tenant", selectedInterface.tenant());
163+
}
164+
165+
@Test
166+
public void shouldPreserveEmptyTenant() throws A2AClientException {
167+
ClientBuilder builder = Client
168+
.builder(card)
169+
.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder());
170+
171+
AgentInterface selectedInterface = builder.findBestClientTransport();
172+
173+
Assertions.assertEquals("", selectedInterface.tenant());
174+
}
94175
}

spec/src/main/java/io/a2a/spec/DataPart.java

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package io.a2a.spec;
22

33

4+
import com.google.gson.Gson;
5+
import com.google.gson.GsonBuilder;
6+
import com.google.gson.JsonSyntaxException;
7+
import com.google.gson.ToNumberPolicy;
48
import io.a2a.util.Assert;
59
import java.util.Map;
610
import org.jspecify.annotations.Nullable;
@@ -77,4 +81,59 @@ public DataPart (Object data, @Nullable Map<String, Object> metadata) {
7781
public DataPart(Object data) {
7882
this(data, null);
7983
}
84+
85+
/**
86+
* Creates a DataPart by parsing a JSON string into its corresponding Java type.
87+
* <p>
88+
* The JSON string is parsed using Gson with {@code ToNumberPolicy.LONG_OR_DOUBLE},
89+
* producing the following mappings:
90+
* <ul>
91+
* <li>JSON objects → {@code Map<String, Object>}</li>
92+
* <li>JSON arrays → {@code List<Object>}</li>
93+
* <li>JSON strings → {@code String}</li>
94+
* <li>JSON integers → {@code Long}</li>
95+
* <li>JSON decimals → {@code Double}</li>
96+
* <li>JSON booleans → {@code Boolean}</li>
97+
* </ul>
98+
* <p>
99+
* Example usage:
100+
* <pre>{@code
101+
* DataPart dataPart = DataPart.fromJson("""
102+
* {
103+
* "temperature": 22.5,
104+
* "humidity": 65
105+
* }""");
106+
* }</pre>
107+
*
108+
* @param json the JSON string to parse (must not be null or the JSON literal "null")
109+
* @return a new DataPart containing the parsed data
110+
* @throws IllegalArgumentException if json is null, parses to null, or is not valid
111+
*/
112+
public static DataPart fromJson(String json) {
113+
return fromJson(json, null);
114+
}
115+
116+
/**
117+
* Creates a DataPart by parsing a JSON string into its corresponding Java type,
118+
* with optional metadata.
119+
*
120+
* @param json the JSON string to parse (must not be null or the JSON literal "null")
121+
* @param metadata additional metadata for the part
122+
* @return a new DataPart containing the parsed data and metadata
123+
* @throws IllegalArgumentException if json is null, parses to null, or is not valid
124+
* @see #fromJson(String)
125+
*/
126+
public static DataPart fromJson(String json, @Nullable Map<String, Object> metadata) {
127+
Assert.checkNotNullParam("json", json);
128+
try {
129+
Object data = JSON_PARSER.fromJson(json, Object.class);
130+
return new DataPart(data, metadata);
131+
} catch (JsonSyntaxException e) {
132+
throw new IllegalArgumentException("Invalid JSON: " + json, e);
133+
}
134+
}
135+
136+
private static final Gson JSON_PARSER = new GsonBuilder()
137+
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
138+
.create();
80139
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package io.a2a.spec;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
5+
import static org.junit.jupiter.api.Assertions.assertNull;
6+
import static org.junit.jupiter.api.Assertions.assertThrows;
7+
8+
import java.util.List;
9+
import java.util.Map;
10+
11+
import org.junit.jupiter.api.Test;
12+
13+
class DataPartTest {
14+
15+
@Test
16+
void testFromJson_object() {
17+
DataPart part = DataPart.fromJson("""
18+
{"temperature": 22.5, "humidity": 65}""");
19+
20+
Map<String, Object> data = assertInstanceOf(Map.class, part.data());
21+
assertEquals(22.5, data.get("temperature"));
22+
assertEquals(65L, data.get("humidity"));
23+
assertNull(part.metadata());
24+
}
25+
26+
@Test
27+
void testFromJson_array() {
28+
DataPart part = DataPart.fromJson("""
29+
["a", "b", "c"]""");
30+
31+
List<Object> data = assertInstanceOf(List.class, part.data());
32+
assertEquals(List.of("a", "b", "c"), data);
33+
}
34+
35+
@Test
36+
void testFromJson_string() {
37+
DataPart part = DataPart.fromJson("\"hello\"");
38+
39+
assertEquals("hello", part.data());
40+
}
41+
42+
@Test
43+
void testFromJson_integerNumber() {
44+
DataPart part = DataPart.fromJson("42");
45+
46+
assertEquals(42L, part.data());
47+
}
48+
49+
@Test
50+
void testFromJson_decimalNumber() {
51+
DataPart part = DataPart.fromJson("3.14");
52+
53+
assertEquals(3.14, part.data());
54+
}
55+
56+
@Test
57+
void testFromJson_boolean() {
58+
DataPart part = DataPart.fromJson("true");
59+
60+
assertEquals(true, part.data());
61+
}
62+
63+
@Test
64+
void testFromJson_withMetadata() {
65+
Map<String, Object> metadata = Map.of("source", "sensor");
66+
DataPart part = DataPart.fromJson("""
67+
{"temperature": 22.5}""", metadata);
68+
69+
assertInstanceOf(Map.class, part.data());
70+
assertEquals("sensor", part.metadata().get("source"));
71+
}
72+
73+
@Test
74+
void testFromJson_nestedObject() {
75+
DataPart part = DataPart.fromJson("""
76+
{"outer": {"inner": [1, 2, 3]}}""");
77+
78+
Map<String, Object> data = assertInstanceOf(Map.class, part.data());
79+
Map<String, Object> outer = assertInstanceOf(Map.class, data.get("outer"));
80+
assertEquals(List.of(1L, 2L, 3L), outer.get("inner"));
81+
}
82+
83+
@Test
84+
void testFromJson_nullJsonThrows() {
85+
assertThrows(IllegalArgumentException.class, () -> DataPart.fromJson(null));
86+
}
87+
88+
@Test
89+
void testFromJson_nullLiteralThrows() {
90+
assertThrows(IllegalArgumentException.class, () -> DataPart.fromJson("null"));
91+
}
92+
93+
@Test
94+
void testFromJson_invalidJsonThrows() {
95+
assertThrows(IllegalArgumentException.class, () -> DataPart.fromJson("{invalid}"));
96+
}
97+
}

0 commit comments

Comments
 (0)