Skip to content

Commit a9f001e

Browse files
kvmiloscopybara-github
authored andcommitted
feat(a2a): add an A2A v1.0 client with Android support and deprecate the v0.3 JvmA2AAgent
PiperOrigin-RevId: 940379674
1 parent 4feb461 commit a9f001e

22 files changed

Lines changed: 3796 additions & 95 deletions

File tree

a2a/build.gradle.kts

Lines changed: 29 additions & 7 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,22 @@ kotlin {
3845
val commonJvmAndroidMain by creating {
3946
dependsOn(commonMain)
4047
dependencies {
41-
implementation(libs.jackson.databind)
42-
implementation(libs.jackson.datatype.jsr310)
48+
implementation(libs.kotlinx.serialization)
49+
implementation(libs.a2a.sdk.client)
50+
implementation(libs.a2a.sdk.common)
51+
implementation(libs.a2a.sdk.spec)
4352
}
4453
}
45-
// jvmMain: deprecated v0.3 (`io.a2a.*`) path, JVM-only.
54+
// jvmMain hosts the deprecated v0.3 path (JVM-only); androidMain stays v1.0-only.
4655
val jvmMain by getting {
4756
dependsOn(commonJvmAndroidMain)
4857
dependencies {
58+
// JVM v1.0 factory uses the SDK's proto-based JSON-RPC transport; kept off the Android
59+
// path.
60+
implementation(libs.a2a.sdk.transport.jsonrpc)
61+
// Jackson is JVM-only (deprecated v0.3 converters); kept off the Android artifact.
62+
implementation(libs.jackson.databind)
63+
implementation(libs.jackson.datatype.jsr310)
4964
implementation(libs.jackson.module.kotlin)
5065
implementation(libs.a2a.legacy.sdk.client)
5166
implementation(libs.a2a.legacy.sdk.common)
@@ -58,19 +73,26 @@ kotlin {
5873
implementation(libs.google.truth)
5974
implementation(libs.mockito.kotlin)
6075
implementation(libs.kotlinx.coroutines.test)
76+
implementation(libs.okhttp.mockwebserver)
6177
implementation(libs.a2a.legacy.sdk.client)
6278
implementation(libs.a2a.legacy.sdk.spec)
6379
}
6480
}
81+
val androidMain by getting {
82+
dependsOn(commonJvmAndroidMain)
83+
dependencies { implementation(libs.a2a.sdk.http.client.android) }
84+
}
6585
}
6686
}
6787

6888
// Coordinates the Kotlin Multiplatform plugin uses for the publications it
6989
// 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.
90+
// - `kotlinMultiplatform` -> google-adk-kotlin-a2a (root metadata)
91+
// - `jvm` -> google-adk-kotlin-a2a-jvm (KMP target)
92+
// - `androidRelease` -> google-adk-kotlin-a2a-android (KMP target)
93+
// Per-target suffixes (`-jvm`, `-android`) are appended by the KMP plugin
94+
// automatically. POM metadata, Dokka javadoc, and GPG signing are configured in
95+
// the root build.gradle.kts.
7496
publishing {
7597
publications.withType<MavenPublication>().configureEach {
7698
if (name == "kotlinMultiplatform") {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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.A2AAgentImpl
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.config.ClientConfig
28+
import org.a2aproject.sdk.client.http.A2AHttpClient
29+
import org.a2aproject.sdk.client.http.AndroidA2AHttpClient
30+
import org.a2aproject.sdk.spec.AgentCard
31+
32+
/**
33+
* Builds a framework-internal Android A2A [Client] backed by the proto-free, non-streaming
34+
* [JsonRpcHttpClientTransport] (which uses [httpClient], an [AndroidA2AHttpClient] by default).
35+
*/
36+
@OptIn(FrameworkInternalApi::class)
37+
internal fun androidA2AClient(
38+
agentCard: AgentCard,
39+
httpClient: A2AHttpClient = AndroidA2AHttpClient(),
40+
): Client =
41+
Client.builder(agentCard)
42+
.clientConfig(ClientConfig.Builder().setStreaming(false).build())
43+
.withTransport(
44+
JsonRpcHttpClientTransport::class.java,
45+
JsonRpcHttpClientTransportConfig(httpClient),
46+
)
47+
.build()
48+
49+
/**
50+
* Builds an Android [A2AAgent] from an already-resolved [agentCard], wiring up the Android client
51+
* so the caller never supplies a client and card separately.
52+
*
53+
* The Android proto-free transport supports only non-streaming `message/send`, so the agent always
54+
* runs in non-streaming mode regardless of the remote card's streaming capability.
55+
*/
56+
fun AndroidA2AAgent(
57+
name: String,
58+
agentCard: AgentCard,
59+
httpClient: A2AHttpClient = AndroidA2AHttpClient(),
60+
subAgents: List<BaseAgent> = emptyList(),
61+
beforeAgentCallbacks: List<BeforeAgentCallback> = emptyList(),
62+
afterAgentCallbacks: List<AfterAgentCallback> = emptyList(),
63+
): BaseRemoteA2AAgent =
64+
A2AAgentImpl(
65+
name = name,
66+
a2aClient = androidA2AClient(agentCard, httpClient),
67+
agentCard = agentCard,
68+
streaming = false,
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+
subAgents: List<BaseAgent> = emptyList(),
84+
beforeAgentCallbacks: List<BeforeAgentCallback> = emptyList(),
85+
afterAgentCallbacks: List<AfterAgentCallback> = emptyList(),
86+
): BaseRemoteA2AAgent =
87+
AndroidA2AAgent(
88+
name = name,
89+
agentCard = resolveAgentCard(httpClient, agentCardUrl),
90+
httpClient = httpClient,
91+
subAgents = subAgents,
92+
beforeAgentCallbacks = beforeAgentCallbacks,
93+
afterAgentCallbacks = afterAgentCallbacks,
94+
)
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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+
val body = serializeSendMessage(request)
64+
65+
try {
66+
val response =
67+
httpClient
68+
.createPost()
69+
.url(url)
70+
.addHeader(A2AHttpClient.CONTENT_TYPE, A2AHttpClient.APPLICATION_JSON)
71+
.addHeader(A2A_VERSION_HEADER, A2A_VERSION)
72+
.body(body)
73+
.post()
74+
if (response.status() < 200 || response.status() >= 300) {
75+
throw A2AClientException("Unexpected HTTP status: ${response.status()}")
76+
}
77+
return parseSendMessageResponse(response.body())
78+
} catch (e: A2AClientException) {
79+
throw e
80+
} catch (e: InterruptedException) {
81+
Thread.currentThread().interrupt()
82+
throw A2AClientException("Android A2A HTTP round-trip interrupted", e)
83+
} catch (e: Exception) {
84+
throw A2AClientException("Android A2A HTTP round-trip failed", e)
85+
}
86+
}
87+
88+
/**
89+
* Serializes a `message/send` request to its JSON-RPC wire form.
90+
*
91+
* [JsonUtil] serializes a `Message` (a `StreamingEventKind`) with a `{"<kind>": {...}}` wrapper,
92+
* which double-wraps `params.message`; a request needs the bare message, so we strip one layer:
93+
* ```
94+
* JsonUtil: "params": { "message": { "message": {...} } }
95+
* wire form: "params": { "message": {...} }
96+
* ```
97+
*/
98+
private fun serializeSendMessage(request: MessageSendParams): String =
99+
try {
100+
val envelope: JsonObject =
101+
JsonParser.parseString(
102+
JsonUtil.toJson(SendMessageRequest(JSONRPC_VERSION, REQUEST_ID, request))
103+
)
104+
.asJsonObject
105+
val params = envelope.getAsJsonObject("params")
106+
params.add("message", params.getAsJsonObject("message").get("message"))
107+
envelope.toString()
108+
} catch (e: JsonProcessingException) {
109+
throw A2AClientException("Failed to serialize A2A request", e)
110+
}
111+
112+
// --- Unused operations -------------------------------------------------------------------------
113+
114+
override fun sendMessageStreaming(
115+
request: MessageSendParams,
116+
eventConsumer: Consumer<StreamingEventKind>,
117+
errorConsumer: Consumer<Throwable>,
118+
context: ClientCallContext?,
119+
): Unit =
120+
throw UnsupportedOperationException("streaming not supported by JsonRpcHttpClientTransport")
121+
122+
override fun getTask(request: TaskQueryParams, context: ClientCallContext?): Task =
123+
throw UnsupportedOperationException()
124+
125+
override fun cancelTask(request: CancelTaskParams, context: ClientCallContext?): Task =
126+
throw UnsupportedOperationException()
127+
128+
override fun listTasks(request: ListTasksParams, context: ClientCallContext?): ListTasksResult =
129+
throw UnsupportedOperationException()
130+
131+
override fun createTaskPushNotificationConfiguration(
132+
request: TaskPushNotificationConfig,
133+
context: ClientCallContext?,
134+
): TaskPushNotificationConfig = throw UnsupportedOperationException()
135+
136+
override fun getTaskPushNotificationConfiguration(
137+
request: GetTaskPushNotificationConfigParams,
138+
context: ClientCallContext?,
139+
): TaskPushNotificationConfig = throw UnsupportedOperationException()
140+
141+
override fun listTaskPushNotificationConfigurations(
142+
request: ListTaskPushNotificationConfigsParams,
143+
context: ClientCallContext?,
144+
): ListTaskPushNotificationConfigsResult = throw UnsupportedOperationException()
145+
146+
override fun deleteTaskPushNotificationConfigurations(
147+
request: DeleteTaskPushNotificationConfigParams,
148+
context: ClientCallContext?,
149+
): Unit = throw UnsupportedOperationException()
150+
151+
override fun subscribeToTask(
152+
request: TaskIdParams,
153+
eventConsumer: Consumer<StreamingEventKind>,
154+
errorConsumer: Consumer<Throwable>,
155+
context: ClientCallContext?,
156+
): Unit = throw UnsupportedOperationException()
157+
158+
override fun getExtendedAgentCard(
159+
params: GetExtendedAgentCardParams,
160+
context: ClientCallContext?,
161+
): AgentCard = throw UnsupportedOperationException()
162+
163+
override fun close() {}
164+
165+
private companion object {
166+
const val JSONRPC_VERSION = "2.0"
167+
const val REQUEST_ID = "1"
168+
const val A2A_VERSION_HEADER = "A2A-Version"
169+
const val A2A_VERSION = "1.0"
170+
171+
/** Parses a JSON-RPC `message/send` response body into its result [EventKind]. */
172+
fun parseSendMessageResponse(responseBody: String): EventKind {
173+
val envelope = JsonParser.parseString(responseBody).asJsonObject
174+
175+
val errorNode = envelope.get("error")
176+
if (errorNode != null && !errorNode.isJsonNull) {
177+
throw A2AClientException("A2A JSON-RPC error: $errorNode")
178+
}
179+
180+
val resultNode = envelope.get("result")
181+
if (resultNode == null || !resultNode.isJsonObject) {
182+
throw A2AClientException("A2A JSON-RPC response missing 'result' object")
183+
}
184+
185+
return try {
186+
// Result is a Task/Message; the SDK's StreamingEventKind adapter picks the concrete type.
187+
JsonUtil.fromJson(resultNode.toString(), StreamingEventKind::class.java) as EventKind
188+
} catch (e: JsonProcessingException) {
189+
throw A2AClientException("Failed to parse A2A response result", e)
190+
}
191+
}
192+
}
193+
}
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)