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
25 changes: 15 additions & 10 deletions .claude/architecture/compatibility_0.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ The A2A protocol evolved from v0.3 to v1.0 with significant breaking changes. Ex
- Changes to existing v1.0 modules (no regressions, no API changes)
- Automatic protocol version detection (client must explicitly choose API version)
- Extras modules (OpenTelemetry, JPA stores, etc.) for v0.3
- v0.3 format agent card (v0.3 clients must be able to parse v1.0 agent card format)
- Serving a separate v0.3-format agent card (the v1.0 card is served, with optional v0.3-compatible fields added by the user)

---

Expand Down Expand Up @@ -149,19 +149,24 @@ v0.3 Client Response

`TASK_STATE_REJECTED` (v1.0-only) is mapped to `TASK_STATE_FAILED` when converting to v0.3 wire format. The original state is preserved in metadata (`"original_state": "REJECTED"`) so information is not entirely lost. Both are terminal states, so v0.3 clients can handle the result correctly.

### Agent Card: v1.0 Only
### Agent Card Precedence in Multi-Version Mode

The agent card (`/.well-known/agent-card.json`) is produced only using the v1.0 format. The compat layer does not produce a v0.3-format agent card.
When both v1.0 and v0.3 protocol support are enabled, a single agent card is served at `/.well-known/agent-card.json`:

A server that supports both versions should advertise this via a single v1.0 agent card containing multiple `AgentInterface` entries — one per version — each with its own URL and `protocolVersion` field. v0.3 clients must be able to parse the v1.0 agent card format to discover their endpoint.
- **Both v1.0 and v0.3 enabled**: The v1.0 `AgentCard` takes precedence. The v0.3 `AgentCard_v0_3` is ignored.
- **Only v0.3 enabled**: The v0.3 `AgentCard_v0_3` is used.
- **Only v1.0 enabled**: The v1.0 `AgentCard` is used as-is.

**Pros:**
- **Single source of truth**: one agent card at one well-known URL describes all supported versions and transports
- **Simpler server implementation**: no need for separate v0.3 agent card endpoint, serializer, or CDI producer
- **Forward-looking**: encourages v0.3 clients to understand the v1.0 discovery format
### Making the v1.0 Agent Card Parsable by v0.3 Clients

**Cons:**
- **v0.3 clients must parse v1.0 agent card**: pure v0.3 clients that only understand the v0.3 structure need updating
Existing v0.3 client implementations (across all languages) expect specific fields in the agent card that don't exist in the v1.0 format. Since we cannot control what external v0.3 clients can parse, the v1.0 `AgentCard` must include backward-compatible fields when serving both protocol versions:

- `url` and `preferredTransport` — top-level fields that v0.3 clients use to discover the primary endpoint
- `additionalInterfaces` — a list of `Legacy_0_3_AgentInterface(transport, url)` entries using v0.3 field names (`transport` instead of v1.0's `protocolBinding`)

These fields coexist alongside the v1.0 `supportedInterfaces` field. v1.0 clients use `supportedInterfaces`; v0.3 clients use `url`, `preferredTransport`, and `additionalInterfaces`.

`Legacy_0_3_AgentInterface` is a separate record from `AgentInterface` because they serialize to different JSON field names (`transport`/`url` vs `protocolBinding`/`url`/`tenant`).

---

Expand Down
111 changes: 107 additions & 4 deletions .github/workflows/run-tck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ on:
env:
# Tag/branch of the TCK
TCK_VERSION: 1.0-dev
TCK_VERSION_0_3: 0.3.0.beta4
# Tells uv to not need a venv, and instead use system
UV_SYSTEM_PYTHON: 1
SUT_URL: http://localhost:9999
# Slow system on CI
TCK_STREAMING_TIMEOUT_0_3: 5.0

# Only run the latest job
concurrency:
Expand All @@ -27,6 +30,7 @@ jobs:
strategy:
matrix:
java-version: [17]
profile: ['', 'multi-mode']
steps:
- name: Checkout a2a-java
uses: actions/checkout@v6
Expand Down Expand Up @@ -54,7 +58,7 @@ jobs:
uv pip install -e .
working-directory: a2a-tck
- name: Start SUT
run: mvn -B quarkus:dev -Dquarkus.console.enabled=false &
run: mvn -B quarkus:dev ${{ matrix.profile && format('-P{0}', matrix.profile) || '' }} -Dquarkus.console.enabled=false &
working-directory: tck
- name: Wait for SUT to start
run: |
Expand Down Expand Up @@ -97,7 +101,7 @@ jobs:
# Extract everything after the first ═══ separator line
SUMMARY=$(sed -n '/^═══/,$p' a2a-tck/tck-output.log)
if [ -n "$SUMMARY" ]; then
echo '### TCK Results (Java ${{ matrix.java-version }})' >> $GITHUB_STEP_SUMMARY
echo '### TCK 1.0 Results (Java ${{ matrix.java-version }}${{ matrix.profile && format(', {0}', matrix.profile) || '' }})' >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$SUMMARY" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
Expand All @@ -112,7 +116,106 @@ jobs:
if: always()
uses: actions/upload-artifact@v7
with:
name: tck-reports-java-${{ matrix.java-version }}
name: tck-reports-java-${{ matrix.java-version }}${{ matrix.profile && format('-{0}', matrix.profile) || '' }}
path: a2a-tck/reports/
retention-days: 14
if-no-files-found: warn
if-no-files-found: warn

tck-test-0-3:
runs-on: ubuntu-latest
strategy:
matrix:
java-version: [17]
profile: ['', 'multi-mode']
steps:
- name: Checkout a2a-java
uses: actions/checkout@v6
- name: Set up JDK ${{ matrix.java-version }}
uses: actions/setup-java@v5
with:
java-version: ${{ matrix.java-version }}
distribution: 'temurin'
cache: maven
- name: Build a2a-java SDK
run: mvn -B install -DskipTests
- name: Checkout a2a-tck
uses: actions/checkout@v6
with:
repository: a2aproject/a2a-tck
path: a2a-tck
ref: ${{ env.TCK_VERSION_0_3 }}
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: "a2a-tck/pyproject.toml"
- name: Install uv and Python dependencies
run: |
pip install uv
uv pip install -e .
working-directory: a2a-tck
- name: Start SUT
run: mvn -B quarkus:dev ${{ matrix.profile && format('-P{0}', matrix.profile) || '' }} -Dquarkus.console.enabled=false &
working-directory: compat-0.3/tck
- name: Wait for SUT to start
run: |
URL="${{ env.SUT_URL }}/.well-known/agent-card.json"
EXPECTED_STATUS=200
TIMEOUT=120
RETRY_INTERVAL=2
START_TIME=$(date +%s)

while true; do
CURRENT_TIME=$(date +%s)
ELAPSED_TIME=$((CURRENT_TIME - START_TIME))

if [ "$ELAPSED_TIME" -ge "$TIMEOUT" ]; then
echo "Timeout: Server did not respond with status $EXPECTED_STATUS within $TIMEOUT seconds."
exit 1
fi

HTTP_STATUS=$(curl --output /dev/null --silent --write-out "%{http_code}" "$URL") || true

if [ "$HTTP_STATUS" -eq "$EXPECTED_STATUS" ]; then
echo "Server is up! Received status $HTTP_STATUS after $ELAPSED_TIME seconds."
break;
fi

echo "Server not ready (status: $HTTP_STATUS). Retrying in $RETRY_INTERVAL seconds..."
sleep "$RETRY_INTERVAL"
done
- name: Run TCK
id: run-tck
timeout-minutes: 5
env:
TCK_STREAMING_TIMEOUT: ${{ env.TCK_STREAMING_TIMEOUT_0_3 }}
run: |
set -o pipefail
./run_tck.py --sut-url ${{ env.SUT_URL }} --category all --transports jsonrpc,grpc,rest --compliance-report report.json 2>&1 | tee tck-output.log
working-directory: a2a-tck
- name: TCK Summary
if: always() && steps.run-tck.outcome != 'skipped'
run: |
if [ -f a2a-tck/tck-output.log ]; then
SUMMARY=$(sed -n '/^═══/,$p' a2a-tck/tck-output.log)
if [ -n "$SUMMARY" ]; then
echo '### TCK 0.3 Results (Java ${{ matrix.java-version }}${{ matrix.profile && format(', {0}', matrix.profile) || '' }})' >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$SUMMARY" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Stop SUT
if: always()
run: |
pkill -f "quarkus:dev" || true
sleep 2
- name: Upload TCK Reports
if: always()
uses: actions/upload-artifact@v7
with:
name: tck-0.3-reports-java-${{ matrix.java-version }}${{ matrix.profile && format('-{0}', matrix.profile) || '' }}
path: |
a2a-tck/reports/
a2a-tck/report.json
retention-days: 14
if-no-files-found: warn
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,33 @@ These are a convenience — they transitively include all the individual compat

- **JSON-RPC and REST**: When serving multiple protocol versions, version routing inspects the `A2A-Version` HTTP header on each request. If the header is `"1.0"`, the request is routed to the v1.0 handler. If it is `"0.3"` or absent, the request is routed to the v0.3 handler.
- **gRPC**: Version dispatch is implicit — v0.3 clients use the `a2a.v1` protobuf package and v1.0 clients use `lf.a2a.v1`, so requests are routed to the correct service automatically.
- **Agent card**: The agent card is served in v1.0 format only. Older clients must be able to parse the v1.0 agent card.
- **Agent card**: When both v1.0 and v0.3 are enabled, the v1.0 `AgentCard` takes precedence and is served at `/.well-known/agent-card.json`. The v0.3 `AgentCard_v0_3` is ignored. If only v0.3 is enabled, the v0.3 agent card is used. If only v1.0 is enabled, the v1.0 agent card is used as-is.

#### Making the v1.0 Agent Card Compatible with v0.3 Clients

When serving both protocol versions, you need to ensure the v1.0 agent card contains fields that v0.3 clients expect. Existing v0.3 client implementations (in any language) look for `url`, `preferredTransport`, and `additionalInterfaces` with `transport`/`url` entries — fields that don't exist in the v1.0 format by default.

To make your v1.0 `AgentCard` parsable by v0.3 clients, set these fields on the builder:

```java
AgentCard card = AgentCard.builder()
.name("My Agent")
// ... other v1.0 fields ...
.supportedInterfaces(List.of(
new AgentInterface("jsonrpc", "http://localhost:9999")))
// v0.3 backward-compatibility fields:
.url("http://localhost:9999")
.preferredTransport("jsonrpc")
.additionalInterfaces(List.of(
new Legacy_0_3_AgentInterface("jsonrpc", "http://localhost:9999")))
.build();
```

The two interface lists serve different clients:

- `supportedInterfaces` — used by **v1.0 clients** to discover endpoints (uses `AgentInterface` with `protocolBinding`/`url`/`tenant` fields)
- `additionalInterfaces` — used by **v0.3 clients** to discover endpoints (uses `Legacy_0_3_AgentInterface` with v0.3 field names: `transport`/`url`)
- `url` and `preferredTransport` — top-level fields that v0.3 clients use to discover the primary endpoint

## A2A Client

Expand Down
6 changes: 3 additions & 3 deletions common/src/main/java/org/a2aproject/sdk/util/Assert.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ private static <T> void checkNotNullParamChecked(final String name, final @Nulla
}
}

public static void isNullOrStringOrInteger(@Nullable Object value) {
if (! (value == null || value instanceof String || value instanceof Integer)) {
throw new IllegalArgumentException("Id must be null, a String, or an Integer");
public static void isValidJsonRpcId(@Nullable Object value) {
if (! (value == null || value instanceof String || value instanceof Number)) {
throw new IllegalArgumentException("JSON-RPC id must be null, a String, or a Number");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public CancelTaskRequest_v0_3(String jsonrpc, Object id, String method, TaskIdPa
throw new IllegalArgumentException("Invalid CancelTaskRequest method");
}
Assert.checkNotNullParam("params", params);
Assert.isNullOrStringOrInteger(id);
Assert.isValidJsonRpcId(id);
this.jsonrpc = defaultIfNull(jsonrpc, JSONRPC_VERSION);
this.id = id;
this.method = method;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public DeleteTaskPushNotificationConfigRequest_v0_3(String jsonrpc, Object id, S
if (! method.equals(METHOD)) {
throw new IllegalArgumentException("Invalid DeleteTaskPushNotificationConfigRequest method");
}
Assert.isNullOrStringOrInteger(id);
Assert.isValidJsonRpcId(id);
this.jsonrpc = Utils_v0_3.defaultIfNull(jsonrpc, JSONRPC_VERSION);
this.id = id;
this.method = method;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public GetAuthenticatedExtendedCardRequest_v0_3(String jsonrpc, Object id, Strin
if (! method.equals(METHOD)) {
throw new IllegalArgumentException("Invalid GetAuthenticatedExtendedCardRequest method");
}
Assert.isNullOrStringOrInteger(id);
Assert.isValidJsonRpcId(id);
this.jsonrpc = Utils_v0_3.defaultIfNull(jsonrpc, JSONRPC_VERSION);
this.id = id;
this.method = method;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public GetTaskPushNotificationConfigRequest_v0_3(String jsonrpc, Object id, Stri
if (! method.equals(METHOD)) {
throw new IllegalArgumentException("Invalid GetTaskPushNotificationRequest method");
}
Assert.isNullOrStringOrInteger(id);
Assert.isValidJsonRpcId(id);
this.jsonrpc = Utils_v0_3.defaultIfNull(jsonrpc, JSONRPC_VERSION);
this.id = id;
this.method = method;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public GetTaskRequest_v0_3(String jsonrpc, Object id, String method, TaskQueryPa
throw new IllegalArgumentException("Invalid GetTaskRequest method");
}
Assert.checkNotNullParam("params", params);
Assert.isNullOrStringOrInteger(id);
Assert.isValidJsonRpcId(id);
this.jsonrpc = defaultIfNull(jsonrpc, JSONRPC_VERSION);
this.id = id;
this.method = method;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public JSONRPCRequest_v0_3() {
public JSONRPCRequest_v0_3(String jsonrpc, Object id, String method, T params) {
Assert.checkNotNullParam("jsonrpc", jsonrpc);
Assert.checkNotNullParam("method", method);
Assert.isNullOrStringOrInteger(id);
Assert.isValidJsonRpcId(id);
this.jsonrpc = defaultIfNull(jsonrpc, JSONRPC_VERSION);
this.id = id;
this.method = method;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public JSONRPCResponse_v0_3(String jsonrpc, Object id, T result, JSONRPCError_v0
if (error == null && result == null && ! Void.class.equals(resultType)) {
throw new IllegalArgumentException("Invalid JSON-RPC success response");
}
Assert.isNullOrStringOrInteger(id);
Assert.isValidJsonRpcId(id);
this.jsonrpc = defaultIfNull(jsonrpc, JSONRPC_VERSION);
this.id = id;
this.result = result;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public ListTaskPushNotificationConfigRequest_v0_3(String jsonrpc, Object id, Str
if (! method.equals(METHOD)) {
throw new IllegalArgumentException("Invalid ListTaskPushNotificationConfigRequest method");
}
Assert.isNullOrStringOrInteger(id);
Assert.isValidJsonRpcId(id);
this.jsonrpc = Utils_v0_3.defaultIfNull(jsonrpc, JSONRPC_VERSION);
this.id = id;
this.method = method;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public SendMessageRequest_v0_3(String jsonrpc, Object id, String method, Message
throw new IllegalArgumentException("Invalid SendMessageRequest method");
}
Assert.checkNotNullParam("params", params);
Assert.isNullOrStringOrInteger(id);
Assert.isValidJsonRpcId(id);
this.jsonrpc = defaultIfNull(jsonrpc, JSONRPC_VERSION);
this.id = id;
this.method = method;
Expand All @@ -56,7 +56,7 @@ public void check() {
throw new IllegalArgumentException("Invalid SendMessageRequest method");
}
Assert.checkNotNullParam("params", params);
Assert.isNullOrStringOrInteger(id);
Assert.isValidJsonRpcId(id);
params.check();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public SendStreamingMessageRequest_v0_3(String jsonrpc, Object id, String method
throw new IllegalArgumentException("Invalid SendStreamingMessageRequest method");
}
Assert.checkNotNullParam("params", params);
Assert.isNullOrStringOrInteger(id);
Assert.isValidJsonRpcId(id);
this.jsonrpc = defaultIfNull(jsonrpc, JSONRPC_VERSION);
this.id = id;
this.method = method;
Expand All @@ -38,7 +38,7 @@ public void check() {
throw new IllegalArgumentException("Invalid SendStreamingMessageRequest method");
}
Assert.checkNotNullParam("params", params);
Assert.isNullOrStringOrInteger(id);
Assert.isValidJsonRpcId(id);
params.check();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public SetTaskPushNotificationConfigRequest_v0_3(String jsonrpc, Object id, Stri
throw new IllegalArgumentException("Invalid SetTaskPushNotificationRequest method");
}
Assert.checkNotNullParam("params", params);
Assert.isNullOrStringOrInteger(id);
Assert.isValidJsonRpcId(id);
this.jsonrpc = defaultIfNull(jsonrpc, JSONRPC_VERSION);
this.id = id;
this.method = method;
Expand Down
Loading
Loading