Skip to content
Open
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
6 changes: 3 additions & 3 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
matrix:
java-version: ['17', '21', '25']
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up JDK ${{ matrix.java-version }}
uses: actions/setup-java@v4
with:
Expand All @@ -29,7 +29,7 @@ jobs:
run: mvn -B package --file pom.xml -fae
- name: Upload Test Reports
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: surefire-reports-java-${{ matrix.java-version }}
path: |
Expand All @@ -39,7 +39,7 @@ jobs:
if-no-files-found: warn
- name: Upload Build Logs
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: build-logs-java-${{ matrix.java-version }}
path: |
Expand Down
71 changes: 30 additions & 41 deletions .github/workflows/build-with-release-profile.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
name: Build with '-Prelease'

# Simply runs the build with -Prelease to avoid nasty surprises when running the release-to-maven-central workflow.
name: Build with '-Prelease' (Trigger)

# Trigger workflow for release profile build verification.
# This workflow runs on PRs and uploads the PR info for the workflow_run job.
# The actual build with secrets happens in build-with-release-profile-run.yml
# See: https://securitylab.github.com/research/github-actions-preventing-pwn-requests

on:
# Handle all branches for now
pull_request: # Changed from pull_request_target for security
push:
pull_request_target:
workflow_dispatch:

# Only run the latest job
Expand All @@ -15,47 +16,35 @@ concurrency:
cancel-in-progress: true

jobs:
build:
trigger:
# Only run this job for the main repository, not for forks
if: github.repository == 'a2aproject/a2a-java'
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: maven

# Use secrets to import GPG key
- name: Import GPG key
uses: crazy-max/ghaction-import-gpg@v6
with:
gpg_private_key: ${{ secrets.GPG_SIGNING_KEY }}
passphrase: ${{ secrets.GPG_SIGNING_PASSPHRASE }}

# Create settings.xml for Maven since it needs the 'central-a2asdk-temp' server.
# Populate wqith username and password from secrets
- name: Create settings.xml
- name: Prepare PR info
run: |
mkdir -p ~/.m2
echo "<settings><servers><server><id>central-a2asdk-temp</id><username>${{ secrets.CENTRAL_TOKEN_USERNAME }}</username><password>${{ secrets.CENTRAL_TOKEN_PASSWORD }}</password></server></servers></settings>" > ~/.m2/settings.xml

# Build with the same settings as the deploy job
# -s uses the settings file we created.
- name: Build with same arguments as deploy job
run: >
mvn -B install
-s ~/.m2/settings.xml
-P release
-DskipTests
-Drelease.auto.publish=true
env:
# GPG passphrase is set as an environment variable for the gpg plugin to use
GPG_PASSPHRASE: ${{ secrets.GPG_SIGNING_PASSPHRASE }}
mkdir -p pr_info

# Store PR number for workflow_run job
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo ${{ github.event.number }} > pr_info/pr_number
echo ${{ github.event.pull_request.head.sha }} > pr_info/pr_sha
echo ${{ github.event.pull_request.head.ref }} > pr_info/pr_ref
else
# For push events, store the commit sha
echo ${{ github.sha }} > pr_info/pr_sha
echo ${{ github.ref }} > pr_info/pr_ref
fi

echo "Event: ${{ github.event_name }}"
cat pr_info/*

- name: Upload PR info
uses: actions/upload-artifact@v6
with:
name: pr-info
path: pr_info/
retention-days: 1
8 changes: 4 additions & 4 deletions .github/workflows/cloud-deployment-example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v4

uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
Expand All @@ -27,7 +26,7 @@ jobs:

- name: Install Kind
run: |
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.20.0/kind-linux-amd64
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.31.0/kind-linux-amd64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kind
kind version
Expand Down Expand Up @@ -58,7 +57,8 @@ jobs:
mvn test-compile exec:java \
-Dexec.mainClass="io.a2a.examples.cloud.A2ACloudExampleClient" \
-Dexec.classpathScope=test \
-Dagent.url=http://localhost:8080
-Dagent.url=http://localhost:8080 \
-Dci.mode=true

- name: Show diagnostics on failure
if: failure()
Expand Down
4 changes: 2 additions & 2 deletions examples/cloud-deployment/k8s/02-kafka.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ metadata:
strimzi.io/kraft: enabled
spec:
kafka:
version: 4.0.0
metadataVersion: 4.0-IV0
version: 4.2.0
metadataVersion: 4.2-IV0
listeners:
- name: plain
port: 9092
Expand Down
21 changes: 21 additions & 0 deletions examples/cloud-deployment/scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@ if ! kubectl get namespace kafka > /dev/null 2>&1; then
fi

if ! kubectl get crd kafkas.kafka.strimzi.io > /dev/null 2>&1; then
# Keep this around in case we need to hardcode operator version again in the future
# echo "Installing Strimzi operator... at https://github.com/strimzi/strimzi-kafka-operator/releases/download/0.50.1/strimzi-cluster-operator-0.50.1.yaml"
# curl -sL 'https://github.com/strimzi/strimzi-kafka-operator/releases/download/0.50.1/strimzi-cluster-operator-0.50.1.yaml' \
# | sed 's/namespace: .*/namespace: kafka/' \
# | kubectl apply -f - -n kafka
echo "Installing Strimzi operator..."
kubectl create -f 'https://strimzi.io/install/latest?namespace=kafka' -n kafka

Expand Down Expand Up @@ -212,6 +217,22 @@ echo ""
echo "Deploying PostgreSQL..."
kubectl apply -f ../k8s/01-postgres.yaml
echo "Waiting for PostgreSQL to be ready..."

# Wait for pod to be created (StatefulSet takes time to create pod)
for i in {1..30}; do
if kubectl get pod -l app=postgres -n a2a-demo 2>/dev/null | grep -q postgres; then
echo "PostgreSQL pod found, waiting for ready state..."
break
fi
if [ $i -eq 30 ]; then
echo -e "${RED}ERROR: PostgreSQL pod not created after 30 seconds${NC}"
kubectl get statefulset -n a2a-demo
exit 1
fi
sleep 1
done

# Now wait for pod to be ready
kubectl wait --for=condition=Ready pod -l app=postgres -n a2a-demo --timeout=120s
echo -e "${GREEN}✓ PostgreSQL deployed${NC}"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,7 @@ public void invokeJSONRPCHandler(@Body String body, RoutingContext rc) {
throw new JSONParseError(e.getMessage());
}

// Validate jsonrpc field
com.google.gson.JsonElement jsonrpcElement = node.get("jsonrpc");
if (jsonrpcElement == null || !jsonrpcElement.isJsonPrimitive()
|| !JSONRPCMessage.JSONRPC_VERSION.equals(jsonrpcElement.getAsString())) {
throw new InvalidRequestError("Invalid JSON-RPC request: missing or invalid 'jsonrpc' field");
}

// Validate id field (must be string, number, or null — not an object or array)
// Extract id field early so error responses can include it
com.google.gson.JsonElement idElement = node.get("id");
if (idElement != null && !idElement.isJsonNull() && !idElement.isJsonPrimitive()) {
throw new InvalidRequestError("Invalid JSON-RPC request: 'id' must be a string, number, or null");
Expand All @@ -117,6 +110,13 @@ public void invokeJSONRPCHandler(@Body String body, RoutingContext rc) {
requestId = idPrimitive.isNumber() ? idPrimitive.getAsLong() : idPrimitive.getAsString();
}

// Validate jsonrpc field
com.google.gson.JsonElement jsonrpcElement = node.get("jsonrpc");
if (jsonrpcElement == null || !jsonrpcElement.isJsonPrimitive()
|| !JSONRPCMessage.JSONRPC_VERSION.equals(jsonrpcElement.getAsString())) {
throw new InvalidRequestError("Invalid JSON-RPC request: missing or invalid 'jsonrpc' field");
}

// Validate method field
com.google.gson.JsonElement methodElement = node.get("method");
if (methodElement == null || !methodElement.isJsonPrimitive()) {
Expand Down
109 changes: 103 additions & 6 deletions spec/src/main/java/io/a2a/json/JsonUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@
import com.google.gson.JsonSyntaxException;
import com.google.gson.ToNumberPolicy;
import com.google.gson.TypeAdapter;
import com.google.gson.TypeAdapterFactory;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import io.a2a.spec.APIKeySecurityScheme;
import io.a2a.spec.EventKind;
import io.a2a.spec.JSONRPCResponse;
import io.a2a.spec.ContentTypeNotSupportedError;
import io.a2a.spec.DataPart;
import io.a2a.spec.FileContent;
Expand Down Expand Up @@ -76,7 +79,10 @@ private static GsonBuilder createBaseGsonBuilder() {
.registerTypeAdapter(Message.Role.class, new RoleTypeAdapter())
.registerTypeAdapter(Part.Kind.class, new PartKindTypeAdapter())
.registerTypeHierarchyAdapter(FileContent.class, new FileContentTypeAdapter())
.registerTypeHierarchyAdapter(SecurityScheme.class, new SecuritySchemeTypeAdapter());
.registerTypeHierarchyAdapter(SecurityScheme.class, new SecuritySchemeTypeAdapter())
.registerTypeAdapter(void.class, new VoidTypeAdapter())
.registerTypeAdapter(Void.class, new VoidTypeAdapter())
.registerTypeAdapterFactory(new JSONRPCResponseTypeAdapterFactory());
}

/**
Expand All @@ -87,7 +93,6 @@ private static GsonBuilder createBaseGsonBuilder() {
* <p>
* Used throughout the SDK for consistent JSON serialization and deserialization.
*
* @see GsonFactory#createGson()
*/
public static final Gson OBJECT_MAPPER = createBaseGsonBuilder()
.registerTypeHierarchyAdapter(Part.class, new PartTypeAdapter())
Expand Down Expand Up @@ -725,8 +730,7 @@ public void write(JsonWriter out, StreamingEventKind value) throws java.io.IOExc
}

@Override
public @Nullable
StreamingEventKind read(JsonReader in) throws java.io.IOException {
public @Nullable StreamingEventKind read(JsonReader in) throws java.io.IOException {
if (in.peek() == com.google.gson.stream.JsonToken.NULL) {
in.nextNull();
return null;
Expand Down Expand Up @@ -875,8 +879,7 @@ public void write(JsonWriter out, FileContent value) throws java.io.IOException
}

@Override
public @Nullable
FileContent read(JsonReader in) throws java.io.IOException {
public @Nullable FileContent read(JsonReader in) throws java.io.IOException {
if (in.peek() == com.google.gson.stream.JsonToken.NULL) {
in.nextNull();
return null;
Expand All @@ -901,4 +904,98 @@ FileContent read(JsonReader in) throws java.io.IOException {
}
}

static class VoidTypeAdapter extends TypeAdapter<Void> {


@Override
@SuppressWarnings("resource")
public void write(final JsonWriter out, final Void value) throws java.io.IOException {
out.nullValue();
}

@Override
public @Nullable Void read(final JsonReader in) throws java.io.IOException {
in.skipValue();
return null;
}

}

/**
* Gson TypeAdapterFactory for serializing {@link JSONRPCResponse} subclasses.
* <p>
* JSON-RPC 2.0 requires that:
* <ul>
* <li>{@code result} MUST be present (possibly null) on success, and MUST NOT be present on error</li>
* <li>{@code error} MUST be present on error, and MUST NOT be present on success</li>
* </ul>
* Gson's default null-field-skipping behavior would omit {@code "result": null} for Void responses,
* so this factory writes the fields explicitly to comply with the spec.
*/
static class JSONRPCResponseTypeAdapterFactory implements TypeAdapterFactory {

@Override
@SuppressWarnings({"unchecked", "rawtypes"})
public @Nullable <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
if (!JSONRPCResponse.class.isAssignableFrom(type.getRawType())) {
return null;
}

TypeAdapter<T> delegateAdapter = gson.getDelegateAdapter(this, type);
TypeAdapter<JSONRPCError> errorAdapter = gson.getAdapter(JSONRPCError.class);

return new TypeAdapter<T>() {
@Override
public void write(JsonWriter out, T value) throws java.io.IOException {
if (value == null) {
out.nullValue();
return;
}

JSONRPCResponse<?> response = (JSONRPCResponse<?>) value;

out.beginObject();
out.name("jsonrpc").value(response.getJsonrpc());

Object id = response.getId();
out.name("id");
if (id == null) {
out.nullValue();
} else if (id instanceof Number n) {
out.value(n.longValue());
} else {
out.value(id.toString());
}

JSONRPCError error = response.getError();
if (error != null) {
out.name("error");
errorAdapter.write(out, error);
} else {
out.name("result");
Object result = response.getResult();
if (result == null) {
// JsonWriter.nullValue() skips both name+value when serializeNulls=false,
// so we must temporarily enable it to write "result":null as required
// by JSON-RPC 2.0.
boolean prev = out.getSerializeNulls();
out.setSerializeNulls(true);
out.nullValue();
out.setSerializeNulls(prev);
} else {
TypeAdapter resultAdapter = gson.getAdapter(result.getClass());
resultAdapter.write(out, result);
}
}

out.endObject();
}

@Override
public T read(JsonReader in) throws java.io.IOException {
return delegateAdapter.read(in);
}
};
}
}
}
5 changes: 5 additions & 0 deletions spec/src/main/java/io/a2a/spec/AgentCapabilities.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

/**
* Defines optional capabilities supported by an agent.
*
* @param streaming whether the agent supports streaming responses
* @param pushNotifications whether the agent supports push notifications
* @param stateTransitionHistory whether the agent supports state transition history
* @param extensions optional list of protocol extensions supported by the agent
*/
public record AgentCapabilities(boolean streaming, boolean pushNotifications, boolean stateTransitionHistory,
List<AgentExtension> extensions) {
Expand Down
Loading
Loading