Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,6 @@
<!-- Ensure tests run with proper module path for Testcontainers -->
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<!-- workaround for https://github.com/testcontainers/testcontainers-java/issues/11212 -->
<api.version>1.44</api.version>
</systemPropertyVariables>
</configuration>
</plugin>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Map;
import java.util.Set;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import com.google.gson.ToNumberPolicy;
import com.google.gson.TypeAdapter;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import com.google.gson.stream.JsonWriter;
Expand Down Expand Up @@ -500,10 +502,18 @@ public void write(JsonWriter out, Message.Role value) throws java.io.IOException
static class PartTypeAdapter extends TypeAdapter<Part<?>> {

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

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

private void writeMetadata(JsonWriter out, @Nullable Map<String, Object> metadata) throws java.io.IOException {
if (metadata != null && !metadata.isEmpty()) {
out.name("metadata");
delegateGson.toJson(metadata, MAP_TYPE, out);
}
}

@Override
public void write(JsonWriter out, Part<?> value) throws java.io.IOException {
if (value == null) {
Expand All @@ -517,14 +527,17 @@ public void write(JsonWriter out, Part<?> value) throws java.io.IOException {
// TextPart: { "text": "value" } - direct string value
out.name(TEXT);
out.value(textPart.text());
writeMetadata(out, textPart.metadata());
} else if (value instanceof FilePart filePart) {
// FilePart: { "file": {...} }
out.name(FILE);
delegateGson.toJson(filePart.file(), FileContent.class, out);
writeMetadata(out, filePart.metadata());
} else if (value instanceof DataPart dataPart) {
// DataPart: { "data": <any JSON value> }
out.name(DATA);
delegateGson.toJson(dataPart.data(), Object.class, out);
writeMetadata(out, dataPart.metadata());
} else {
throw new JsonSyntaxException("Unknown Part subclass: " + value.getClass().getName());
}
Expand All @@ -548,24 +561,34 @@ Part<?> read(JsonReader in) throws java.io.IOException {

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

// Extract metadata if present
Map<String, Object> metadata = null;
if (jsonObject.has("metadata")) {
metadata = delegateGson.fromJson(jsonObject.get("metadata"), new TypeToken<Map<String, Object>>(){}.getType());
}

// Check for member name discriminators (v1.0 protocol)
Set<String> keys = jsonObject.keySet();
if (keys.size() != 1) {
throw new JsonSyntaxException(format("Part object must have exactly one key, which must be one of: %s (found: %s)", VALID_KEYS, keys));
if (keys.size() < 1 || keys.size() > 2) {
throw new JsonSyntaxException(format("Part object must have one content key from %s and optionally 'metadata' (found: %s)", VALID_KEYS, keys));
}

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

return switch (discriminator) {
case TEXT -> new TextPart(jsonObject.get(TEXT).getAsString());
case FILE -> new FilePart(delegateGson.fromJson(jsonObject.get(FILE), FileContent.class));
case TEXT -> new TextPart(jsonObject.get(TEXT).getAsString(), metadata);
case FILE -> new FilePart(delegateGson.fromJson(jsonObject.get(FILE), FileContent.class), metadata);
case DATA -> {
// DataPart supports any JSON value: object, array, primitive, or null
Object data = delegateGson.fromJson(
jsonObject.get(DATA),
Object.class
);
yield new DataPart(data);
yield new DataPart(data, metadata);
}
default ->
throw new JsonSyntaxException(format("Part must have one of: %s (found: %s)", VALID_KEYS, discriminator));
Expand Down
8 changes: 8 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
<rest-assured.version>5.5.1</rest-assured.version>
<slf4j.version>2.0.17</slf4j.version>
<logback.version>1.5.18</logback.version>
<version.testcontainers>1.21.4</version.testcontainers>
<error-prone.version>2.47.0</error-prone.version>
<nullaway.version>0.13.1</nullaway.version>
<error-prone.flag>-XDaddTypeAnnotationsToSymbol=true</error-prone.flag>
Expand Down Expand Up @@ -226,6 +227,13 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>${version.testcontainers}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-bom</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package io.a2a.server.util;

import java.util.List;
import java.util.Map;
import java.util.UUID;

import io.a2a.spec.Artifact;
Expand Down
14 changes: 9 additions & 5 deletions spec-grpc/src/main/java/io/a2a/grpc/mapper/PartMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import io.a2a.spec.InvalidRequestError;
import io.a2a.spec.Part;
import io.a2a.spec.TextPart;
import java.util.Map;
import org.mapstruct.Mapper;

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

if (domain instanceof TextPart textPart) {
builder.setText(textPart.text());
builder.setMetadata(A2ACommonFieldMapper.INSTANCE.metadataToProto(textPart.metadata()));
} else if (domain instanceof FilePart filePart) {
FileContent fileContent = filePart.file();

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

return builder.build();
Expand All @@ -85,26 +89,26 @@ default Part<?> fromProto(io.a2a.grpc.Part proto) {
if (proto == null) {
return null;
}

Map<String, Object> metadata = A2ACommonFieldMapper.INSTANCE.metadataFromProto(proto.getMetadata());
if (proto.hasText()) {
return new TextPart(proto.getText());
return new TextPart(proto.getText(), metadata);
} else if (proto.hasRaw()) {
// raw bytes → FilePart(FileWithBytes)
String bytes = Base64.getEncoder().encodeToString(proto.getRaw().toByteArray());
String mimeType = proto.getMediaType().isEmpty() ? null : proto.getMediaType();
String name = proto.getFilename().isEmpty() ? null : proto.getFilename();
return new FilePart(new FileWithBytes(mimeType, name, bytes));
return new FilePart(new FileWithBytes(mimeType, name, bytes), metadata);
} else if (proto.hasUrl()) {
// url → FilePart(FileWithUri)
String uri = proto.getUrl();
String mimeType = proto.getMediaType().isEmpty() ? null : proto.getMediaType();
String name = proto.getFilename().isEmpty() ? null : proto.getFilename();
return new FilePart(new FileWithUri(mimeType, name, uri));
return new FilePart(new FileWithUri(mimeType, name, uri), metadata);
} else if (proto.hasData()) {
// data (google.protobuf.Value containing any JSON value) → DataPart
Value dataValue = proto.getData();
Object data = A2ACommonFieldMapper.INSTANCE.valueToObject(dataValue);
return new DataPart(data);
return new DataPart(data, metadata);
}

throw new InvalidRequestError();
Expand Down
51 changes: 11 additions & 40 deletions spec/src/main/java/io/a2a/spec/DataPart.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@


import io.a2a.util.Assert;
import java.util.Map;
import org.jspecify.annotations.Nullable;


Expand Down Expand Up @@ -37,11 +38,12 @@
* }</pre>
*
* @param data the structured data (required, supports JSON objects, arrays, primitives, and null)
* @param metadata additional metadata for the part
* @see Part
* @see Message
* @see Artifact
*/
public record DataPart(Object data) implements Part<Object> {
public record DataPart(Object data, @Nullable Map<String, Object> metadata) implements Part<Object> {

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

/**
* Create a new Builder
* Constructor.
*
* @return the builder
*/
public static Builder builder() {
return new Builder();
}

/**
* Builder for constructing {@link DataPart} instances.
* @param data the structured data (supports JSON objects, arrays, primitives, and not null)
* @throws IllegalArgumentException if data is null
*/
public static class Builder {
private @Nullable Object data;

/**
* Creates a new Builder with all fields unset.
*/
private Builder() {
}

/**
* Sets the structured data.
*
* @param data the structured data (required, supports JSON objects, arrays, primitives, and null)
* @return this builder for method chaining
*/
public Builder data(Object data) {
this.data = data;
return this;
}

/**
* Builds a new {@link DataPart} from the current builder state.
*
* @return a new DataPart instance
* @throws IllegalArgumentException if data is null
*/
public DataPart build() {
return new DataPart(Assert.checkNotNullParam("data", data));
}
public DataPart(Object data) {
this(data, null);
}
}
19 changes: 17 additions & 2 deletions spec/src/main/java/io/a2a/spec/FilePart.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@


import io.a2a.util.Assert;
import java.util.Map;
import org.jspecify.annotations.Nullable;


/**
Expand Down Expand Up @@ -31,12 +33,13 @@
* }</pre>
*
* @param file the file content (required, either FileWithBytes or FileWithUri)
* @param metadata additional metadata for the part
* @see Part
* @see FileContent
* @see FileWithBytes
* @see FileWithUri
*/
public record FilePart(FileContent file) implements Part<FileContent> {
public record FilePart(FileContent file, @Nullable Map<String, Object> metadata) implements Part<FileContent> {

/**
* The JSON member name discriminator for file parts: "file".
Expand All @@ -52,7 +55,19 @@ public record FilePart(FileContent file) implements Part<FileContent> {
* @param file the file content (required, either FileWithBytes or FileWithUri)
* @throws IllegalArgumentException if file is null
*/
public FilePart {
public FilePart (FileContent file, @Nullable Map<String, Object> metadata) {
Assert.checkNotNullParam("file", file);
this.metadata = metadata == null ? null : Map.copyOf(metadata);
this.file = file;
}

/**
* Constructor.
*
* @param file the file content (required, either FileWithBytes or FileWithUri)
* @throws IllegalArgumentException if file is null
*/
public FilePart (FileContent file) {
this(file, null);
}
}
14 changes: 6 additions & 8 deletions spec/src/main/java/io/a2a/spec/Message.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,12 @@
* @see <a href="https://a2a-protocol.org/latest/">A2A Protocol Specification</a>
*/
public record Message(Role role, List<Part<?>> parts,
String messageId, @Nullable
String contextId,
@Nullable
String taskId, @Nullable
List<String> referenceTaskIds,
@Nullable
Map<String, Object> metadata, @Nullable
List<String> extensions
String messageId,
@Nullable String contextId,
@Nullable String taskId,
@Nullable List<String> referenceTaskIds,
@Nullable Map<String, Object> metadata,
@Nullable List<String> extensions
) implements EventKind, StreamingEventKind {

/**
Expand Down
19 changes: 17 additions & 2 deletions spec/src/main/java/io/a2a/spec/TextPart.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@


import io.a2a.util.Assert;
import java.util.Map;
import org.jspecify.annotations.Nullable;


/**
Expand All @@ -20,11 +22,12 @@
* }</pre>
*
* @param text the text content (required, must not be null)
* @param metadata additional metadata for the part
* @see Part
* @see Message
* @see Artifact
*/
public record TextPart(String text) implements Part<String> {
public record TextPart(String text, @Nullable Map<String, Object> metadata) implements Part<String> {

/**
* The JSON member name discriminator for text parts: "text".
Expand All @@ -40,7 +43,19 @@ public record TextPart(String text) implements Part<String> {
* @param text the text content (required, must not be null)
* @throws IllegalArgumentException if text is null
*/
public TextPart {
public TextPart (String text, @Nullable Map<String, Object> metadata) {
Assert.checkNotNullParam("text", text);
this.metadata = metadata == null ? null : Map.copyOf(metadata);
this.text = text;
}

/**
* Constructor.
*
* @param text the text content (required, must not be null)
* @throws IllegalArgumentException if data is null
*/
public TextPart (String text){
this(text, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ public class GrpcHandlerTest extends AbstractA2ARequestHandlerTest {
.setContextId(AbstractA2ARequestHandlerTest.MINIMAL_TASK.contextId())
.setMessageId(AbstractA2ARequestHandlerTest.MESSAGE.messageId())
.setRole(Role.ROLE_AGENT)
.addParts(Part.newBuilder().setText(((TextPart) AbstractA2ARequestHandlerTest.MESSAGE.parts().get(0)).text()).build())
.addParts(Part.newBuilder()
.setText(((TextPart) AbstractA2ARequestHandlerTest.MESSAGE.parts().get(0)).text())
.setMetadata(Struct.newBuilder().build())
.build())
.setMetadata(Struct.newBuilder().build())
.build();

Expand Down
Loading