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
31 changes: 26 additions & 5 deletions a2a/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,17 @@

plugins {
kotlin("multiplatform")
id("com.android.kotlin.multiplatform.library")
id("maven-publish")
}

kotlin {
// AGP 9 KMP Android library target (replaces com.android.library + androidTarget).
android {
namespace = "com.google.adk.a2a"
compileSdk = rootProject.extra["androidCompileSdk"] as Int
minSdk = rootProject.extra["androidMinSdk"] as Int
}
jvm()

sourceSets {
Expand All @@ -38,14 +45,21 @@ kotlin {
val commonJvmAndroidMain by creating {
dependsOn(commonMain)
dependencies {
implementation(libs.kotlinx.serialization)
implementation(libs.jackson.databind)
implementation(libs.jackson.datatype.jsr310)
implementation(libs.a2a.sdk.client)
implementation(libs.a2a.sdk.common)
implementation(libs.a2a.sdk.spec)
implementation(libs.a2a.sdk.transport.rest)
}
}
// jvmMain: deprecated v0.3 (`io.a2a.*`) path, JVM-only.
// jvmMain hosts the deprecated v0.3 path (JVM-only); androidMain stays v1.0-only.
val jvmMain by getting {
dependsOn(commonJvmAndroidMain)
dependencies {
// Only the deprecated v0.3 (JVM-only) converters use jackson-module-kotlin; keep it off the
// Android path, which serializes via kotlinx.serialization instead.
implementation(libs.jackson.module.kotlin)
implementation(libs.a2a.legacy.sdk.client)
implementation(libs.a2a.legacy.sdk.common)
Expand All @@ -58,19 +72,26 @@ kotlin {
implementation(libs.google.truth)
implementation(libs.mockito.kotlin)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.okhttp.mockwebserver)
implementation(libs.a2a.legacy.sdk.client)
implementation(libs.a2a.legacy.sdk.spec)
}
}
val androidMain by getting {
dependsOn(commonJvmAndroidMain)
dependencies { implementation(libs.a2a.sdk.http.client.android) }
}
}
}

// Coordinates the Kotlin Multiplatform plugin uses for the publications it
// auto-creates:
// - `kotlinMultiplatform` -> google-adk-kotlin-a2a (root metadata)
// - `jvm` -> google-adk-kotlin-a2a-jvm (KMP target)
// POM metadata, Dokka javadoc, and GPG signing are configured in the root
// build.gradle.kts.
// - `kotlinMultiplatform` -> google-adk-kotlin-a2a (root metadata)
// - `jvm` -> google-adk-kotlin-a2a-jvm (KMP target)
// - `androidRelease` -> google-adk-kotlin-a2a-android (KMP target)
// Per-target suffixes (`-jvm`, `-android`) are appended by the KMP plugin
// automatically. POM metadata, Dokka javadoc, and GPG signing are configured in
// the root build.gradle.kts.
publishing {
publications.withType<MavenPublication>().configureEach {
if (name == "kotlinMultiplatform") {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.adk.kt.a2a.android

import com.google.adk.kt.a2a.agent.A2AAgentImpl
import com.google.adk.kt.a2a.agent.BaseRemoteA2AAgent
import com.google.adk.kt.a2a.agent.resolveAgentCard
import com.google.adk.kt.agents.BaseAgent
import com.google.adk.kt.callbacks.AfterAgentCallback
import com.google.adk.kt.callbacks.BeforeAgentCallback
import org.a2aproject.sdk.client.Client
import org.a2aproject.sdk.client.clientWithTransport
import org.a2aproject.sdk.client.http.A2AHttpClient
import org.a2aproject.sdk.client.http.AndroidA2AHttpClient
import org.a2aproject.sdk.spec.AgentCard
import org.a2aproject.sdk.spec.TransportProtocol

/**
* Builds an A2A [Client] for Android by wiring a proto-free [JsonRpcHttpClientTransport] over
* [httpClient] (an [AndroidA2AHttpClient] by default) directly into a [Client] -- the SDK's own
* JSON-RPC transport marshals through protobuf and isn't Android-buildable. Constructed directly
* (not via the ServiceLoader SPI) so nothing here needs to be reflectively discoverable.
*/
internal fun androidA2AClient(
agentCard: AgentCard,
httpClient: A2AHttpClient = AndroidA2AHttpClient(),
): Client {
// `url()` is non-null under Blaze (JSpecify) but platform-nullable under Gradle's SDK jar, so
// normalize both candidates to String? and require one before building the transport.
val jsonRpcUrl: String? =
agentCard
.supportedInterfaces()
.firstOrNull { it.protocolBinding() == TransportProtocol.JSONRPC.asString() }
?.url()
val cardUrl: String? = agentCard.url()
val url: String = jsonRpcUrl ?: cardUrl ?: error("AgentCard has no transport URL")
return clientWithTransport(agentCard, JsonRpcHttpClientTransport(httpClient, url))
}

/**
* Builds an Android [A2AAgent] from an already-resolved [agentCard], wiring up the Android client
* so the caller never supplies a client and card separately.
*/
fun androidA2AAgent(
name: String,
agentCard: AgentCard,
httpClient: A2AHttpClient = AndroidA2AHttpClient(),
streaming: Boolean = true,
subAgents: List<BaseAgent> = emptyList(),
beforeAgentCallbacks: List<BeforeAgentCallback> = emptyList(),
afterAgentCallbacks: List<AfterAgentCallback> = emptyList(),
): BaseRemoteA2AAgent =
A2AAgentImpl(
name = name,
a2aClient = androidA2AClient(agentCard, httpClient),
agentCard = agentCard,
streaming = streaming,
subAgents = subAgents,
beforeAgentCallbacks = beforeAgentCallbacks,
afterAgentCallbacks = afterAgentCallbacks,
)

/**
* Builds an Android [A2AAgent] from [agentCardUrl], auto-fetching the [AgentCard] from the remote
* agent's `/.well-known/agent-card.json` (like ADK Python/Go). Suspends on the network fetch, so
* call it off the main thread.
*/
suspend fun androidA2AAgent(
name: String,
agentCardUrl: String,
httpClient: A2AHttpClient = AndroidA2AHttpClient(),
streaming: Boolean = true,
subAgents: List<BaseAgent> = emptyList(),
beforeAgentCallbacks: List<BeforeAgentCallback> = emptyList(),
afterAgentCallbacks: List<AfterAgentCallback> = emptyList(),
): BaseRemoteA2AAgent =
androidA2AAgent(
name = name,
agentCard = resolveAgentCard(httpClient, agentCardUrl),
httpClient = httpClient,
streaming = streaming,
subAgents = subAgents,
beforeAgentCallbacks = beforeAgentCallbacks,
afterAgentCallbacks = afterAgentCallbacks,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.adk.kt.a2a.android

import com.google.gson.JsonObject
import com.google.gson.JsonParser
import java.util.function.Consumer
import org.a2aproject.sdk.client.http.A2AHttpClient
import org.a2aproject.sdk.client.transport.spi.ClientTransport
import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext
import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException
import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil
import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult
import org.a2aproject.sdk.jsonrpc.common.wrappers.SendMessageRequest
import org.a2aproject.sdk.spec.A2AClientException
import org.a2aproject.sdk.spec.AgentCard
import org.a2aproject.sdk.spec.CancelTaskParams
import org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams
import org.a2aproject.sdk.spec.EventKind
import org.a2aproject.sdk.spec.GetExtendedAgentCardParams
import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams
import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams
import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult
import org.a2aproject.sdk.spec.ListTasksParams
import org.a2aproject.sdk.spec.MessageSendParams
import org.a2aproject.sdk.spec.StreamingEventKind
import org.a2aproject.sdk.spec.Task
import org.a2aproject.sdk.spec.TaskIdParams
import org.a2aproject.sdk.spec.TaskPushNotificationConfig
import org.a2aproject.sdk.spec.TaskQueryParams

/**
* A proto-free [ClientTransport] that runs a real non-streaming `message/send` JSON-RPC round-trip
* over an injected [A2AHttpClient].
*
* The SDK's `JSONRPCTransport` marshals through protobuf and isn't Android-buildable, so this
* reuses the SDK's proto-free `jsonrpccommon` [JsonUtil] to serialize the request and parse the
* response. The remaining [ClientTransport] methods throw [UnsupportedOperationException].
*
* Implementation detail wired up by `androidA2AClient(...)`; not part of the public API.
*/
internal class JsonRpcHttpClientTransport(
private val httpClient: A2AHttpClient,
private val url: String,
) : ClientTransport {

override fun sendMessage(request: MessageSendParams, context: ClientCallContext?): EventKind {
// Serialize via the SDK's proto-free JSON-RPC machinery (no hand-rolled JSON).
val body: String =
try {
val envelope: JsonObject =
JsonParser.parseString(
JsonUtil.toJson(SendMessageRequest(JSONRPC_VERSION, REQUEST_ID, request))
)
.asJsonObject
// JsonUtil wraps params.message via the StreamingEventKind adapter; unwrap it for the wire.
val params = envelope.getAsJsonObject("params")
params.add("message", params.getAsJsonObject("message").get("message"))
envelope.toString()
} catch (e: JsonProcessingException) {
throw A2AClientException("Failed to serialize A2A request", e)
}

try {
val response =
httpClient
.createPost()
.url(url)
.addHeader(A2AHttpClient.CONTENT_TYPE, A2AHttpClient.APPLICATION_JSON)
.addHeader(A2A_VERSION_HEADER, A2A_VERSION)
.body(body)
.post()
if (response.status() < 200 || response.status() >= 300) {
throw A2AClientException("Unexpected HTTP status: ${response.status()}")
}
return parseSendMessageResponse(response.body())
} catch (e: A2AClientException) {
throw e
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
throw A2AClientException("Android A2A HTTP round-trip interrupted", e)
} catch (e: Exception) {
throw A2AClientException("Android A2A HTTP round-trip failed", e)
}
}

// --- Unused operations -------------------------------------------------------------------------

override fun sendMessageStreaming(
request: MessageSendParams,
eventConsumer: Consumer<StreamingEventKind>,
errorConsumer: Consumer<Throwable>,
context: ClientCallContext?,
): Unit =
throw UnsupportedOperationException("streaming not supported by JsonRpcHttpClientTransport")

override fun getTask(request: TaskQueryParams, context: ClientCallContext?): Task =
throw UnsupportedOperationException()

override fun cancelTask(request: CancelTaskParams, context: ClientCallContext?): Task =
throw UnsupportedOperationException()

override fun listTasks(request: ListTasksParams, context: ClientCallContext?): ListTasksResult =
throw UnsupportedOperationException()

override fun createTaskPushNotificationConfiguration(
request: TaskPushNotificationConfig,
context: ClientCallContext?,
): TaskPushNotificationConfig = throw UnsupportedOperationException()

override fun getTaskPushNotificationConfiguration(
request: GetTaskPushNotificationConfigParams,
context: ClientCallContext?,
): TaskPushNotificationConfig = throw UnsupportedOperationException()

override fun listTaskPushNotificationConfigurations(
request: ListTaskPushNotificationConfigsParams,
context: ClientCallContext?,
): ListTaskPushNotificationConfigsResult = throw UnsupportedOperationException()

override fun deleteTaskPushNotificationConfigurations(
request: DeleteTaskPushNotificationConfigParams,
context: ClientCallContext?,
): Unit = throw UnsupportedOperationException()

override fun subscribeToTask(
request: TaskIdParams,
eventConsumer: Consumer<StreamingEventKind>,
errorConsumer: Consumer<Throwable>,
context: ClientCallContext?,
): Unit = throw UnsupportedOperationException()

override fun getExtendedAgentCard(
params: GetExtendedAgentCardParams,
context: ClientCallContext?,
): AgentCard = throw UnsupportedOperationException()

override fun close() {}

private companion object {
const val JSONRPC_VERSION = "2.0"
const val REQUEST_ID = "1"
const val A2A_VERSION_HEADER = "A2A-Version"
const val A2A_VERSION = "1.0"

/** Parses a JSON-RPC `message/send` response body into its result [EventKind]. */
fun parseSendMessageResponse(responseBody: String): EventKind {
val envelope = JsonParser.parseString(responseBody).asJsonObject

val errorNode = envelope.get("error")
if (errorNode != null && !errorNode.isJsonNull) {
throw A2AClientException("A2A JSON-RPC error: $errorNode")
}

val resultNode = envelope.get("result")
if (resultNode == null || !resultNode.isJsonObject) {
throw A2AClientException("A2A JSON-RPC response missing 'result' object")
}

return try {
// Result is a Task/Message; the SDK's StreamingEventKind adapter picks the concrete type.
JsonUtil.fromJson(resultNode.toString(), StreamingEventKind::class.java) as EventKind
} catch (e: JsonProcessingException) {
throw A2AClientException("Failed to parse A2A response result", e)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// This file lives in the SDK's package so it can reach the package-private `Client` constructor,
// letting us build a Client directly from our transport without the ServiceLoader SPI.
package org.a2aproject.sdk.client

import org.a2aproject.sdk.client.config.ClientConfig
import org.a2aproject.sdk.client.transport.spi.ClientTransport
import org.a2aproject.sdk.spec.AgentCard

/** Builds a [Client] directly from [transport], bypassing the ServiceLoader-based transport SPI. */
internal fun clientWithTransport(agentCard: AgentCard, transport: ClientTransport): Client =
Client(agentCard, ClientConfig.Builder().build(), transport, emptyList(), null)
Loading
Loading