Skip to content

Commit f1d37f5

Browse files
committed
feat: Add metadata support to Part types
Add optional metadata field to TextPart, FilePart, and DataPart records to support additional contextual information for message and artifact content parts as specified in the A2A protocol v1.0. Changes: - Add metadata parameter to Part constructors (TextPart, FilePart, DataPart) - Update PartMapper to convert metadata between domain and gRPC proto - Update JsonUtil PartTypeAdapter to serialize/deserialize metadata Signed-off-by: Emmanuel Hugonnet <ehugonne@redhat.com>
1 parent 727cf33 commit f1d37f5

File tree

9 files changed

+101
-65
lines changed

9 files changed

+101
-65
lines changed

jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/json/JsonUtil.java

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@
2222
import java.time.OffsetDateTime;
2323
import java.time.format.DateTimeFormatter;
2424
import java.time.format.DateTimeParseException;
25+
import java.util.Map;
2526
import java.util.Set;
2627

2728
import com.google.gson.Gson;
2829
import com.google.gson.GsonBuilder;
2930
import com.google.gson.JsonSyntaxException;
3031
import com.google.gson.ToNumberPolicy;
3132
import com.google.gson.TypeAdapter;
33+
import com.google.gson.reflect.TypeToken;
3234
import com.google.gson.stream.JsonReader;
3335
import com.google.gson.stream.JsonToken;
3436
import com.google.gson.stream.JsonWriter;
@@ -500,10 +502,18 @@ public void write(JsonWriter out, Message.Role value) throws java.io.IOException
500502
static class PartTypeAdapter extends TypeAdapter<Part<?>> {
501503

502504
private static final Set<String> VALID_KEYS = Set.of(TEXT, FILE, DATA);
505+
private static final Type MAP_TYPE = new TypeToken<Map<String, Object>>(){}.getType();
503506

504507
// Create separate Gson instance without the Part adapter to avoid recursion
505508
private final Gson delegateGson = createBaseGsonBuilder().create();
506509

510+
private void writeMetadata(JsonWriter out, @Nullable Map<String, Object> metadata) throws java.io.IOException {
511+
if (metadata != null && !metadata.isEmpty()) {
512+
out.name("metadata");
513+
delegateGson.toJson(metadata, MAP_TYPE, out);
514+
}
515+
}
516+
507517
@Override
508518
public void write(JsonWriter out, Part<?> value) throws java.io.IOException {
509519
if (value == null) {
@@ -517,14 +527,17 @@ public void write(JsonWriter out, Part<?> value) throws java.io.IOException {
517527
// TextPart: { "text": "value" } - direct string value
518528
out.name(TEXT);
519529
out.value(textPart.text());
530+
writeMetadata(out, textPart.metadata());
520531
} else if (value instanceof FilePart filePart) {
521532
// FilePart: { "file": {...} }
522533
out.name(FILE);
523534
delegateGson.toJson(filePart.file(), FileContent.class, out);
535+
writeMetadata(out, filePart.metadata());
524536
} else if (value instanceof DataPart dataPart) {
525537
// DataPart: { "data": <any JSON value> }
526538
out.name(DATA);
527539
delegateGson.toJson(dataPart.data(), Object.class, out);
540+
writeMetadata(out, dataPart.metadata());
528541
} else {
529542
throw new JsonSyntaxException("Unknown Part subclass: " + value.getClass().getName());
530543
}
@@ -548,24 +561,34 @@ Part<?> read(JsonReader in) throws java.io.IOException {
548561

549562
com.google.gson.JsonObject jsonObject = jsonElement.getAsJsonObject();
550563

564+
// Extract metadata if present
565+
Map<String, Object> metadata = null;
566+
if (jsonObject.has("metadata")) {
567+
metadata = delegateGson.fromJson(jsonObject.get("metadata"), new TypeToken<Map<String, Object>>(){}.getType());
568+
}
569+
551570
// Check for member name discriminators (v1.0 protocol)
552571
Set<String> keys = jsonObject.keySet();
553-
if (keys.size() != 1) {
554-
throw new JsonSyntaxException(format("Part object must have exactly one key, which must be one of: %s (found: %s)", VALID_KEYS, keys));
572+
if (keys.size() < 1 || keys.size() > 2) {
573+
throw new JsonSyntaxException(format("Part object must have one content key from %s and optionally 'metadata' (found: %s)", VALID_KEYS, keys));
555574
}
556575

557-
String discriminator = keys.iterator().next();
576+
// Find the discriminator (should be one of TEXT, FILE, DATA)
577+
String discriminator = keys.stream()
578+
.filter(VALID_KEYS::contains)
579+
.findFirst()
580+
.orElseThrow(() -> new JsonSyntaxException(format("Part must have one of: %s (found: %s)", VALID_KEYS, keys)));
558581

559582
return switch (discriminator) {
560-
case TEXT -> new TextPart(jsonObject.get(TEXT).getAsString());
561-
case FILE -> new FilePart(delegateGson.fromJson(jsonObject.get(FILE), FileContent.class));
583+
case TEXT -> new TextPart(jsonObject.get(TEXT).getAsString(), metadata);
584+
case FILE -> new FilePart(delegateGson.fromJson(jsonObject.get(FILE), FileContent.class), metadata);
562585
case DATA -> {
563586
// DataPart supports any JSON value: object, array, primitive, or null
564587
Object data = delegateGson.fromJson(
565588
jsonObject.get(DATA),
566589
Object.class
567590
);
568-
yield new DataPart(data);
591+
yield new DataPart(data, metadata);
569592
}
570593
default ->
571594
throw new JsonSyntaxException(format("Part must have one of: %s (found: %s)", VALID_KEYS, discriminator));

pom.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
<rest-assured.version>5.5.1</rest-assured.version>
7070
<slf4j.version>2.0.17</slf4j.version>
7171
<logback.version>1.5.18</logback.version>
72+
<version.testcontainers>1.21.4</version.testcontainers>
7273
<error-prone.version>2.47.0</error-prone.version>
7374
<nullaway.version>0.13.1</nullaway.version>
7475
<error-prone.flag>-XDaddTypeAnnotationsToSymbol=true</error-prone.flag>
@@ -226,6 +227,13 @@
226227
<type>pom</type>
227228
<scope>import</scope>
228229
</dependency>
230+
<dependency>
231+
<groupId>org.testcontainers</groupId>
232+
<artifactId>testcontainers-bom</artifactId>
233+
<version>${version.testcontainers}</version>
234+
<type>pom</type>
235+
<scope>import</scope>
236+
</dependency>
229237
<dependency>
230238
<groupId>io.quarkus</groupId>
231239
<artifactId>quarkus-bom</artifactId>

server-common/src/main/java/io/a2a/server/util/ArtifactUtils.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package io.a2a.server.util;
22

33
import java.util.List;
4-
import java.util.Map;
54
import java.util.UUID;
65

76
import io.a2a.spec.Artifact;

spec-grpc/src/main/java/io/a2a/grpc/mapper/PartMapper.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import io.a2a.spec.InvalidRequestError;
1313
import io.a2a.spec.Part;
1414
import io.a2a.spec.TextPart;
15+
import java.util.Map;
1516
import org.mapstruct.Mapper;
1617

1718
/**
@@ -46,6 +47,7 @@ default io.a2a.grpc.Part toProto(Part<?> domain) {
4647

4748
if (domain instanceof TextPart textPart) {
4849
builder.setText(textPart.text());
50+
builder.setMetadata(A2ACommonFieldMapper.INSTANCE.metadataToProto(textPart.metadata()));
4951
} else if (domain instanceof FilePart filePart) {
5052
FileContent fileContent = filePart.file();
5153

@@ -68,10 +70,12 @@ default io.a2a.grpc.Part toProto(Part<?> domain) {
6870
builder.setMediaType(fileWithUri.mimeType());
6971
}
7072
}
73+
builder.setMetadata(A2ACommonFieldMapper.INSTANCE.metadataToProto(filePart.metadata()));
7174
} else if (domain instanceof DataPart dataPart) {
7275
// Map data to google.protobuf.Value (supports object, array, primitive, or null)
7376
Value dataValue = A2ACommonFieldMapper.INSTANCE.objectToValue(dataPart.data());
7477
builder.setData(dataValue);
78+
builder.setMetadata(A2ACommonFieldMapper.INSTANCE.metadataToProto(dataPart.metadata()));
7579
}
7680

7781
return builder.build();
@@ -85,26 +89,26 @@ default Part<?> fromProto(io.a2a.grpc.Part proto) {
8589
if (proto == null) {
8690
return null;
8791
}
88-
92+
Map<String, Object> metadata = A2ACommonFieldMapper.INSTANCE.metadataFromProto(proto.getMetadata());
8993
if (proto.hasText()) {
90-
return new TextPart(proto.getText());
94+
return new TextPart(proto.getText(), metadata);
9195
} else if (proto.hasRaw()) {
9296
// raw bytes → FilePart(FileWithBytes)
9397
String bytes = Base64.getEncoder().encodeToString(proto.getRaw().toByteArray());
9498
String mimeType = proto.getMediaType().isEmpty() ? null : proto.getMediaType();
9599
String name = proto.getFilename().isEmpty() ? null : proto.getFilename();
96-
return new FilePart(new FileWithBytes(mimeType, name, bytes));
100+
return new FilePart(new FileWithBytes(mimeType, name, bytes), metadata);
97101
} else if (proto.hasUrl()) {
98102
// url → FilePart(FileWithUri)
99103
String uri = proto.getUrl();
100104
String mimeType = proto.getMediaType().isEmpty() ? null : proto.getMediaType();
101105
String name = proto.getFilename().isEmpty() ? null : proto.getFilename();
102-
return new FilePart(new FileWithUri(mimeType, name, uri));
106+
return new FilePart(new FileWithUri(mimeType, name, uri), metadata);
103107
} else if (proto.hasData()) {
104108
// data (google.protobuf.Value containing any JSON value) → DataPart
105109
Value dataValue = proto.getData();
106110
Object data = A2ACommonFieldMapper.INSTANCE.valueToObject(dataValue);
107-
return new DataPart(data);
111+
return new DataPart(data, metadata);
108112
}
109113

110114
throw new InvalidRequestError();

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

Lines changed: 11 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33

44
import io.a2a.util.Assert;
5+
import java.util.Map;
56
import org.jspecify.annotations.Nullable;
67

78

@@ -37,11 +38,12 @@
3738
* }</pre>
3839
*
3940
* @param data the structured data (required, supports JSON objects, arrays, primitives, and null)
41+
* @param metadata additional metadata for the part
4042
* @see Part
4143
* @see Message
4244
* @see Artifact
4345
*/
44-
public record DataPart(Object data) implements Part<Object> {
46+
public record DataPart(Object data, @Nullable Map<String, Object> metadata) implements Part<Object> {
4547

4648
/**
4749
* The JSON member name discriminator for data parts: "data".
@@ -60,50 +62,19 @@ public record DataPart(Object data) implements Part<Object> {
6062
* @param data the structured data (supports JSON objects, arrays, primitives, and null)
6163
* @throws IllegalArgumentException if data is null
6264
*/
63-
public DataPart {
65+
public DataPart (Object data, @Nullable Map<String, Object> metadata) {
6466
Assert.checkNotNullParam("data", data);
67+
this.metadata = metadata == null ? null : Map.copyOf(metadata);
68+
this.data = data;
6569
}
6670

6771
/**
68-
* Create a new Builder
72+
* Constructor.
6973
*
70-
* @return the builder
71-
*/
72-
public static Builder builder() {
73-
return new Builder();
74-
}
75-
76-
/**
77-
* Builder for constructing {@link DataPart} instances.
74+
* @param data the structured data (supports JSON objects, arrays, primitives, and not null)
75+
* @throws IllegalArgumentException if data is null
7876
*/
79-
public static class Builder {
80-
private @Nullable Object data;
81-
82-
/**
83-
* Creates a new Builder with all fields unset.
84-
*/
85-
private Builder() {
86-
}
87-
88-
/**
89-
* Sets the structured data.
90-
*
91-
* @param data the structured data (required, supports JSON objects, arrays, primitives, and null)
92-
* @return this builder for method chaining
93-
*/
94-
public Builder data(Object data) {
95-
this.data = data;
96-
return this;
97-
}
98-
99-
/**
100-
* Builds a new {@link DataPart} from the current builder state.
101-
*
102-
* @return a new DataPart instance
103-
* @throws IllegalArgumentException if data is null
104-
*/
105-
public DataPart build() {
106-
return new DataPart(Assert.checkNotNullParam("data", data));
107-
}
77+
public DataPart(Object data) {
78+
this(data, null);
10879
}
10980
}

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33

44
import io.a2a.util.Assert;
5+
import java.util.Map;
6+
import org.jspecify.annotations.Nullable;
57

68

79
/**
@@ -31,12 +33,13 @@
3133
* }</pre>
3234
*
3335
* @param file the file content (required, either FileWithBytes or FileWithUri)
36+
* @param metadata additional metadata for the part
3437
* @see Part
3538
* @see FileContent
3639
* @see FileWithBytes
3740
* @see FileWithUri
3841
*/
39-
public record FilePart(FileContent file) implements Part<FileContent> {
42+
public record FilePart(FileContent file, @Nullable Map<String, Object> metadata) implements Part<FileContent> {
4043

4144
/**
4245
* The JSON member name discriminator for file parts: "file".
@@ -52,7 +55,19 @@ public record FilePart(FileContent file) implements Part<FileContent> {
5255
* @param file the file content (required, either FileWithBytes or FileWithUri)
5356
* @throws IllegalArgumentException if file is null
5457
*/
55-
public FilePart {
58+
public FilePart (FileContent file, @Nullable Map<String, Object> metadata) {
5659
Assert.checkNotNullParam("file", file);
60+
this.metadata = metadata == null ? null : Map.copyOf(metadata);
61+
this.file = file;
62+
}
63+
64+
/**
65+
* Constructor.
66+
*
67+
* @param file the file content (required, either FileWithBytes or FileWithUri)
68+
* @throws IllegalArgumentException if file is null
69+
*/
70+
public FilePart (FileContent file) {
71+
this(file, null);
5772
}
5873
}

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

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,12 @@
3535
* @see <a href="https://a2a-protocol.org/latest/">A2A Protocol Specification</a>
3636
*/
3737
public record Message(Role role, List<Part<?>> parts,
38-
String messageId, @Nullable
39-
String contextId,
40-
@Nullable
41-
String taskId, @Nullable
42-
List<String> referenceTaskIds,
43-
@Nullable
44-
Map<String, Object> metadata, @Nullable
45-
List<String> extensions
38+
String messageId,
39+
@Nullable String contextId,
40+
@Nullable String taskId,
41+
@Nullable List<String> referenceTaskIds,
42+
@Nullable Map<String, Object> metadata,
43+
@Nullable List<String> extensions
4644
) implements EventKind, StreamingEventKind {
4745

4846
/**

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33

44
import io.a2a.util.Assert;
5+
import java.util.Map;
6+
import org.jspecify.annotations.Nullable;
57

68

79
/**
@@ -20,11 +22,12 @@
2022
* }</pre>
2123
*
2224
* @param text the text content (required, must not be null)
25+
* @param metadata additional metadata for the part
2326
* @see Part
2427
* @see Message
2528
* @see Artifact
2629
*/
27-
public record TextPart(String text) implements Part<String> {
30+
public record TextPart(String text, @Nullable Map<String, Object> metadata) implements Part<String> {
2831

2932
/**
3033
* The JSON member name discriminator for text parts: "text".
@@ -40,7 +43,19 @@ public record TextPart(String text) implements Part<String> {
4043
* @param text the text content (required, must not be null)
4144
* @throws IllegalArgumentException if text is null
4245
*/
43-
public TextPart {
46+
public TextPart (String text, @Nullable Map<String, Object> metadata) {
4447
Assert.checkNotNullParam("text", text);
48+
this.metadata = metadata == null ? null : Map.copyOf(metadata);
49+
this.text = text;
50+
}
51+
52+
/**
53+
* Constructor.
54+
*
55+
* @param text the text content (required, must not be null)
56+
* @throws IllegalArgumentException if data is null
57+
*/
58+
public TextPart (String text){
59+
this(text, null);
4560
}
4661
}

transport/grpc/src/test/java/io/a2a/transport/grpc/handler/GrpcHandlerTest.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ public class GrpcHandlerTest extends AbstractA2ARequestHandlerTest {
5454
.setContextId(AbstractA2ARequestHandlerTest.MINIMAL_TASK.contextId())
5555
.setMessageId(AbstractA2ARequestHandlerTest.MESSAGE.messageId())
5656
.setRole(Role.ROLE_AGENT)
57-
.addParts(Part.newBuilder().setText(((TextPart) AbstractA2ARequestHandlerTest.MESSAGE.parts().get(0)).text()).build())
57+
.addParts(Part.newBuilder()
58+
.setText(((TextPart) AbstractA2ARequestHandlerTest.MESSAGE.parts().get(0)).text())
59+
.setMetadata(Struct.newBuilder().build())
60+
.build())
5861
.setMetadata(Struct.newBuilder().build())
5962
.build();
6063

0 commit comments

Comments
 (0)