Skip to content

Commit a059716

Browse files
kvmiloscopybara-github
authored andcommitted
feat(a2a): add a minimal Android A2A client example
PiperOrigin-RevId: 944461824
1 parent d65becd commit a059716

24 files changed

Lines changed: 3798 additions & 50 deletions

File tree

a2a/build.gradle.kts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,17 @@
1616

1717
plugins {
1818
kotlin("multiplatform")
19+
id("com.android.kotlin.multiplatform.library")
1920
id("maven-publish")
2021
}
2122

2223
kotlin {
24+
// AGP 9 KMP Android library target (replaces com.android.library + androidTarget).
25+
android {
26+
namespace = "com.google.adk.a2a"
27+
compileSdk = rootProject.extra["androidCompileSdk"] as Int
28+
minSdk = rootProject.extra["androidMinSdk"] as Int
29+
}
2330
jvm()
2431

2532
sourceSets {
@@ -38,14 +45,21 @@ kotlin {
3845
val commonJvmAndroidMain by creating {
3946
dependsOn(commonMain)
4047
dependencies {
48+
implementation(libs.kotlinx.serialization)
4149
implementation(libs.jackson.databind)
4250
implementation(libs.jackson.datatype.jsr310)
51+
implementation(libs.a2a.sdk.client)
52+
implementation(libs.a2a.sdk.common)
53+
implementation(libs.a2a.sdk.spec)
54+
implementation(libs.a2a.sdk.transport.rest)
4355
}
4456
}
45-
// jvmMain: deprecated v0.3 (`io.a2a.*`) path, JVM-only.
57+
// jvmMain hosts the deprecated v0.3 path (JVM-only); androidMain stays v1.0-only.
4658
val jvmMain by getting {
4759
dependsOn(commonJvmAndroidMain)
4860
dependencies {
61+
// Only the deprecated v0.3 (JVM-only) converters use jackson-module-kotlin; keep it off the
62+
// Android path, which serializes via kotlinx.serialization instead.
4963
implementation(libs.jackson.module.kotlin)
5064
implementation(libs.a2a.legacy.sdk.client)
5165
implementation(libs.a2a.legacy.sdk.common)
@@ -58,19 +72,26 @@ kotlin {
5872
implementation(libs.google.truth)
5973
implementation(libs.mockito.kotlin)
6074
implementation(libs.kotlinx.coroutines.test)
75+
implementation(libs.okhttp.mockwebserver)
6176
implementation(libs.a2a.legacy.sdk.client)
6277
implementation(libs.a2a.legacy.sdk.spec)
6378
}
6479
}
80+
val androidMain by getting {
81+
dependsOn(commonJvmAndroidMain)
82+
dependencies { implementation(libs.a2a.sdk.http.client.android) }
83+
}
6584
}
6685
}
6786

6887
// Coordinates the Kotlin Multiplatform plugin uses for the publications it
6988
// auto-creates:
70-
// - `kotlinMultiplatform` -> google-adk-kotlin-a2a (root metadata)
71-
// - `jvm` -> google-adk-kotlin-a2a-jvm (KMP target)
72-
// POM metadata, Dokka javadoc, and GPG signing are configured in the root
73-
// build.gradle.kts.
89+
// - `kotlinMultiplatform` -> google-adk-kotlin-a2a (root metadata)
90+
// - `jvm` -> google-adk-kotlin-a2a-jvm (KMP target)
91+
// - `androidRelease` -> google-adk-kotlin-a2a-android (KMP target)
92+
// Per-target suffixes (`-jvm`, `-android`) are appended by the KMP plugin
93+
// automatically. POM metadata, Dokka javadoc, and GPG signing are configured in
94+
// the root build.gradle.kts.
7495
publishing {
7596
publications.withType<MavenPublication>().configureEach {
7697
if (name == "kotlinMultiplatform") {
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.kt.a2a.android
18+
19+
import com.google.adk.kt.a2a.agent.A2AAgent
20+
import com.google.adk.kt.a2a.agent.BaseRemoteA2AAgent
21+
import com.google.adk.kt.a2a.agent.resolveAgentCard
22+
import com.google.adk.kt.agents.BaseAgent
23+
import com.google.adk.kt.annotations.FrameworkInternalApi
24+
import com.google.adk.kt.callbacks.AfterAgentCallback
25+
import com.google.adk.kt.callbacks.BeforeAgentCallback
26+
import org.a2aproject.sdk.client.Client
27+
import org.a2aproject.sdk.client.http.A2AHttpClient
28+
import org.a2aproject.sdk.client.http.AndroidA2AHttpClient
29+
import org.a2aproject.sdk.spec.AgentCard
30+
31+
/**
32+
* Builds an A2A [Client] that works on Android: it wires the SDK builder to the proto-free
33+
* [JsonRpcHttpClientTransport] over [httpClient] (an [AndroidA2AHttpClient] by default), since the
34+
* SDK's own JSON-RPC transport marshals through protobuf and isn't Android-buildable.
35+
*
36+
* This is the recommended entry point for A2A on Android; the transport itself is an implementation
37+
* detail. Pass the resulting [Client] to [com.google.adk.kt.a2a.agent.A2AAgent].
38+
*/
39+
@OptIn(FrameworkInternalApi::class)
40+
fun androidA2AClient(
41+
agentCard: AgentCard,
42+
httpClient: A2AHttpClient = AndroidA2AHttpClient(),
43+
): Client =
44+
Client.builder(agentCard)
45+
.withTransport(
46+
JsonRpcHttpClientTransport::class.java,
47+
JsonRpcHttpClientTransportConfig(httpClient),
48+
)
49+
.build()
50+
51+
/**
52+
* Builds an Android [A2AAgent] from an already-resolved [agentCard], wiring up the Android client
53+
* so the caller never supplies a client and card separately.
54+
*/
55+
fun androidA2AAgent(
56+
name: String,
57+
agentCard: AgentCard,
58+
httpClient: A2AHttpClient = AndroidA2AHttpClient(),
59+
streaming: Boolean = true,
60+
subAgents: List<BaseAgent> = emptyList(),
61+
beforeAgentCallbacks: List<BeforeAgentCallback> = emptyList(),
62+
afterAgentCallbacks: List<AfterAgentCallback> = emptyList(),
63+
): BaseRemoteA2AAgent =
64+
A2AAgent(
65+
name = name,
66+
a2aClient = androidA2AClient(agentCard, httpClient),
67+
agentCard = agentCard,
68+
streaming = streaming,
69+
subAgents = subAgents,
70+
beforeAgentCallbacks = beforeAgentCallbacks,
71+
afterAgentCallbacks = afterAgentCallbacks,
72+
)
73+
74+
/**
75+
* Builds an Android [A2AAgent] from [agentCardUrl], auto-fetching the [AgentCard] from the remote
76+
* agent's `/.well-known/agent-card.json` (like ADK Python/Go). Suspends on the network fetch, so
77+
* call it off the main thread.
78+
*/
79+
suspend fun androidA2AAgent(
80+
name: String,
81+
agentCardUrl: String,
82+
httpClient: A2AHttpClient = AndroidA2AHttpClient(),
83+
streaming: Boolean = true,
84+
subAgents: List<BaseAgent> = emptyList(),
85+
beforeAgentCallbacks: List<BeforeAgentCallback> = emptyList(),
86+
afterAgentCallbacks: List<AfterAgentCallback> = emptyList(),
87+
): BaseRemoteA2AAgent =
88+
androidA2AAgent(
89+
name = name,
90+
agentCard = resolveAgentCard(httpClient, agentCardUrl),
91+
httpClient = httpClient,
92+
streaming = streaming,
93+
subAgents = subAgents,
94+
beforeAgentCallbacks = beforeAgentCallbacks,
95+
afterAgentCallbacks = afterAgentCallbacks,
96+
)
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.kt.a2a.android
18+
19+
import com.google.adk.kt.annotations.FrameworkInternalApi
20+
import com.google.gson.JsonObject
21+
import com.google.gson.JsonParser
22+
import java.util.function.Consumer
23+
import org.a2aproject.sdk.client.http.A2AHttpClient
24+
import org.a2aproject.sdk.client.transport.spi.ClientTransport
25+
import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext
26+
import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException
27+
import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil
28+
import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult
29+
import org.a2aproject.sdk.jsonrpc.common.wrappers.SendMessageRequest
30+
import org.a2aproject.sdk.spec.A2AClientException
31+
import org.a2aproject.sdk.spec.AgentCard
32+
import org.a2aproject.sdk.spec.CancelTaskParams
33+
import org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams
34+
import org.a2aproject.sdk.spec.EventKind
35+
import org.a2aproject.sdk.spec.GetExtendedAgentCardParams
36+
import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams
37+
import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams
38+
import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult
39+
import org.a2aproject.sdk.spec.ListTasksParams
40+
import org.a2aproject.sdk.spec.MessageSendParams
41+
import org.a2aproject.sdk.spec.StreamingEventKind
42+
import org.a2aproject.sdk.spec.Task
43+
import org.a2aproject.sdk.spec.TaskIdParams
44+
import org.a2aproject.sdk.spec.TaskPushNotificationConfig
45+
import org.a2aproject.sdk.spec.TaskQueryParams
46+
47+
/**
48+
* A proto-free [ClientTransport] that runs a real non-streaming `message/send` JSON-RPC round-trip
49+
* over an injected [A2AHttpClient].
50+
*
51+
* The SDK's `JSONRPCTransport` marshals through protobuf and isn't Android-buildable, so this
52+
* reuses the SDK's proto-free `jsonrpccommon` [JsonUtil] to serialize the request and parse the
53+
* response. The remaining [ClientTransport] methods throw [UnsupportedOperationException].
54+
*
55+
* [FrameworkInternalApi]: an implementation detail wired up by `androidA2AClient(...)`; not part of
56+
* the public API.
57+
*/
58+
@FrameworkInternalApi
59+
class JsonRpcHttpClientTransport(private val httpClient: A2AHttpClient, private val url: String) :
60+
ClientTransport {
61+
62+
override fun sendMessage(request: MessageSendParams, context: ClientCallContext?): EventKind {
63+
// Serialize via the SDK's proto-free JSON-RPC machinery (no hand-rolled JSON).
64+
val body: String =
65+
try {
66+
val envelope: JsonObject =
67+
JsonParser.parseString(
68+
JsonUtil.toJson(SendMessageRequest(JSONRPC_VERSION, REQUEST_ID, request))
69+
)
70+
.asJsonObject
71+
// JsonUtil wraps params.message via the StreamingEventKind adapter; unwrap it for the wire.
72+
val params = envelope.getAsJsonObject("params")
73+
params.add("message", params.getAsJsonObject("message").get("message"))
74+
envelope.toString()
75+
} catch (e: JsonProcessingException) {
76+
throw A2AClientException("Failed to serialize A2A request", e)
77+
}
78+
79+
try {
80+
val response =
81+
httpClient
82+
.createPost()
83+
.url(url)
84+
.addHeader(A2AHttpClient.CONTENT_TYPE, A2AHttpClient.APPLICATION_JSON)
85+
.addHeader(A2A_VERSION_HEADER, A2A_VERSION)
86+
.body(body)
87+
.post()
88+
if (response.status() < 200 || response.status() >= 300) {
89+
throw A2AClientException("Unexpected HTTP status: ${response.status()}")
90+
}
91+
return parseSendMessageResponse(response.body())
92+
} catch (e: A2AClientException) {
93+
throw e
94+
} catch (e: InterruptedException) {
95+
Thread.currentThread().interrupt()
96+
throw A2AClientException("Android A2A HTTP round-trip interrupted", e)
97+
} catch (e: Exception) {
98+
throw A2AClientException("Android A2A HTTP round-trip failed", e)
99+
}
100+
}
101+
102+
// --- Unused operations -------------------------------------------------------------------------
103+
104+
override fun sendMessageStreaming(
105+
request: MessageSendParams,
106+
eventConsumer: Consumer<StreamingEventKind>,
107+
errorConsumer: Consumer<Throwable>,
108+
context: ClientCallContext?,
109+
): Unit =
110+
throw UnsupportedOperationException("streaming not supported by JsonRpcHttpClientTransport")
111+
112+
override fun getTask(request: TaskQueryParams, context: ClientCallContext?): Task =
113+
throw UnsupportedOperationException()
114+
115+
override fun cancelTask(request: CancelTaskParams, context: ClientCallContext?): Task =
116+
throw UnsupportedOperationException()
117+
118+
override fun listTasks(request: ListTasksParams, context: ClientCallContext?): ListTasksResult =
119+
throw UnsupportedOperationException()
120+
121+
override fun createTaskPushNotificationConfiguration(
122+
request: TaskPushNotificationConfig,
123+
context: ClientCallContext?,
124+
): TaskPushNotificationConfig = throw UnsupportedOperationException()
125+
126+
override fun getTaskPushNotificationConfiguration(
127+
request: GetTaskPushNotificationConfigParams,
128+
context: ClientCallContext?,
129+
): TaskPushNotificationConfig = throw UnsupportedOperationException()
130+
131+
override fun listTaskPushNotificationConfigurations(
132+
request: ListTaskPushNotificationConfigsParams,
133+
context: ClientCallContext?,
134+
): ListTaskPushNotificationConfigsResult = throw UnsupportedOperationException()
135+
136+
override fun deleteTaskPushNotificationConfigurations(
137+
request: DeleteTaskPushNotificationConfigParams,
138+
context: ClientCallContext?,
139+
): Unit = throw UnsupportedOperationException()
140+
141+
override fun subscribeToTask(
142+
request: TaskIdParams,
143+
eventConsumer: Consumer<StreamingEventKind>,
144+
errorConsumer: Consumer<Throwable>,
145+
context: ClientCallContext?,
146+
): Unit = throw UnsupportedOperationException()
147+
148+
override fun getExtendedAgentCard(
149+
params: GetExtendedAgentCardParams,
150+
context: ClientCallContext?,
151+
): AgentCard = throw UnsupportedOperationException()
152+
153+
override fun close() {}
154+
155+
private companion object {
156+
const val JSONRPC_VERSION = "2.0"
157+
const val REQUEST_ID = "1"
158+
const val A2A_VERSION_HEADER = "A2A-Version"
159+
const val A2A_VERSION = "1.0"
160+
161+
/** Parses a JSON-RPC `message/send` response body into its result [EventKind]. */
162+
fun parseSendMessageResponse(responseBody: String): EventKind {
163+
val envelope = JsonParser.parseString(responseBody).asJsonObject
164+
165+
val errorNode = envelope.get("error")
166+
if (errorNode != null && !errorNode.isJsonNull) {
167+
throw A2AClientException("A2A JSON-RPC error: $errorNode")
168+
}
169+
170+
val resultNode = envelope.get("result")
171+
if (resultNode == null || !resultNode.isJsonObject) {
172+
throw A2AClientException("A2A JSON-RPC response missing 'result' object")
173+
}
174+
175+
return try {
176+
// Result is a Task/Message; the SDK's StreamingEventKind adapter picks the concrete type.
177+
JsonUtil.fromJson(resultNode.toString(), StreamingEventKind::class.java) as EventKind
178+
} catch (e: JsonProcessingException) {
179+
throw A2AClientException("Failed to parse A2A response result", e)
180+
}
181+
}
182+
}
183+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.kt.a2a.android
18+
19+
import com.google.adk.kt.annotations.FrameworkInternalApi
20+
import org.a2aproject.sdk.client.http.A2AHttpClient
21+
import org.a2aproject.sdk.client.transport.spi.ClientTransportConfig
22+
23+
/** Config carrying the [A2AHttpClient] for [JsonRpcHttpClientTransport]. */
24+
@FrameworkInternalApi
25+
class JsonRpcHttpClientTransportConfig(val httpClient: A2AHttpClient) :
26+
ClientTransportConfig<JsonRpcHttpClientTransport>()

0 commit comments

Comments
 (0)