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
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* 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.a2a.converters;

/**
* Enum for the type of A2A metadata. Adds a prefix used to differentiage ADK-related values stored
* in Metadata an A2A event.
*/
public enum A2AMetadataKey {
TYPE("type"),
IS_LONG_RUNNING("is_long_running"),
PARTIAL("partial"),
GROUNDING_METADATA("grounding_metadata"),
USAGE_METADATA("usage_metadata"),
CUSTOM_METADATA("custom_metadata"),
ERROR_CODE("error_code");

private final String type;

private A2AMetadataKey(String type) {
this.type = "adk_" + type;
}

public String getType() {
return type;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* 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.a2a.converters;

/**
* Enum for the type of ADK metadata. Adds a prefix used to differentiate A2A-related values stored
* in custom metadata of an ADK session event.
*/
public enum AdkMetadataKey {
TASK_ID("task_id"),
CONTEXT_ID("context_id");

private final String type;

private AdkMetadataKey(String type) {
this.type = "a2a:" + type;
}

public String getType() {
return type;
}
}
19 changes: 7 additions & 12 deletions a2a/src/main/java/com/google/adk/a2a/converters/PartConverter.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,7 @@ public final class PartConverter {

private static final Logger logger = LoggerFactory.getLogger(PartConverter.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
// Constants for metadata types. By convention metadata keys are prefixed with "adk_" to align
// with the Python and Golang libraries.
public static final String A2A_DATA_PART_METADATA_TYPE_KEY = "adk_type";
public static final String A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY = "adk_is_long_running";
public static final String A2A_DATA_PART_METADATA_IS_PARTIAL_KEY = "adk_partial";
// Constants for metadata types.
public static final String LANGUAGE_KEY = "language";
public static final String OUTCOME_KEY = "outcome";
public static final String CODE_KEY = "code";
Expand Down Expand Up @@ -135,7 +131,7 @@ private static com.google.genai.types.Part convertDataPartToGenAiPart(DataPart d
Map<String, Object> metadata =
Optional.ofNullable(dataPart.getMetadata()).map(HashMap::new).orElseGet(HashMap::new);

String metadataType = metadata.getOrDefault(A2A_DATA_PART_METADATA_TYPE_KEY, "").toString();
String metadataType = metadata.getOrDefault(A2AMetadataKey.TYPE.getType(), "").toString();

if ((data.containsKey(NAME_KEY) && data.containsKey(ARGS_KEY))
|| metadataType.equals(A2ADataPartMetadataType.FUNCTION_CALL.getType())) {
Expand Down Expand Up @@ -218,7 +214,7 @@ private static DataPart createDataPartFromFunctionCall(
addValueIfPresent(data, WILL_CONTINUE_KEY, functionCall.willContinue());
addValueIfPresent(data, PARTIAL_ARGS_KEY, functionCall.partialArgs());

metadata.put(A2A_DATA_PART_METADATA_TYPE_KEY, A2ADataPartMetadataType.FUNCTION_CALL.getType());
metadata.put(A2AMetadataKey.TYPE.getType(), A2ADataPartMetadataType.FUNCTION_CALL.getType());

return new DataPart(data.buildOrThrow(), metadata.buildOrThrow());
}
Expand All @@ -245,7 +241,7 @@ private static DataPart createDataPartFromFunctionResponse(
addValueIfPresent(data, PARTS_KEY, functionResponse.parts());

metadata.put(
A2A_DATA_PART_METADATA_TYPE_KEY, A2ADataPartMetadataType.FUNCTION_RESPONSE.getType());
A2AMetadataKey.TYPE.getType(), A2ADataPartMetadataType.FUNCTION_RESPONSE.getType());

return new DataPart(data.buildOrThrow(), metadata.buildOrThrow());
}
Expand All @@ -268,7 +264,7 @@ private static DataPart createDataPartFromCodeExecutionResult(
addValueIfPresent(data, OUTPUT_KEY, codeExecutionResult.output());

metadata.put(
A2A_DATA_PART_METADATA_TYPE_KEY, A2ADataPartMetadataType.CODE_EXECUTION_RESULT.getType());
A2AMetadataKey.TYPE.getType(), A2ADataPartMetadataType.CODE_EXECUTION_RESULT.getType());

return new DataPart(data.buildOrThrow(), metadata.buildOrThrow());
}
Expand All @@ -290,8 +286,7 @@ private static DataPart createDataPartFromExecutableCode(
.orElse(Language.Known.LANGUAGE_UNSPECIFIED.toString()));
addValueIfPresent(data, CODE_KEY, executableCode.code());

metadata.put(
A2A_DATA_PART_METADATA_TYPE_KEY, A2ADataPartMetadataType.EXECUTABLE_CODE.getType());
metadata.put(A2AMetadataKey.TYPE.getType(), A2ADataPartMetadataType.EXECUTABLE_CODE.getType());

return new DataPart(data.buildOrThrow(), metadata.buildOrThrow());
}
Expand All @@ -305,7 +300,7 @@ public static io.a2a.spec.Part<?> fromGenaiPart(Part part, boolean isPartial) {
}
ImmutableMap.Builder<String, Object> metadata = ImmutableMap.builder();
if (isPartial) {
metadata.put(A2A_DATA_PART_METADATA_IS_PARTIAL_KEY, true);
metadata.put(A2AMetadataKey.PARTIAL.getType(), true);
}

if (part.text().isPresent()) {
Expand Down
131 changes: 113 additions & 18 deletions a2a/src/main/java/com/google/adk/a2a/converters/ResponseConverter.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,20 @@
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.common.collect.Streams.zip;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.adk.agents.InvocationContext;
import com.google.adk.events.Event;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.genai.types.Content;
import com.google.genai.types.CustomMetadata;
import com.google.genai.types.FinishReason;
import com.google.genai.types.GenerateContentResponseUsageMetadata;
import com.google.genai.types.GroundingMetadata;
import com.google.genai.types.Part;
import io.a2a.client.ClientEvent;
import io.a2a.client.MessageEvent;
Expand All @@ -43,11 +51,13 @@
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** Utility for converting ADK events to A2A spec messages (and back). */
public final class ResponseConverter {
private static final ObjectMapper objectMapper = new ObjectMapper();
private static final Logger logger = LoggerFactory.getLogger(ResponseConverter.class);
private static final ImmutableSet<TaskState> PENDING_STATES =
ImmutableSet.of(TaskState.WORKING, TaskState.SUBMITTED);
Expand All @@ -74,12 +84,11 @@ public static Optional<Event> clientEventToEvent(
throw new IllegalArgumentException("Unsupported ClientEvent type: " + event.getClass());
}

private static boolean isPartial(Map<String, Object> metadata) {
private static boolean isPartial(@Nullable Map<String, Object> metadata) {
if (metadata == null) {
return false;
}
return Objects.equals(
metadata.getOrDefault(PartConverter.A2A_DATA_PART_METADATA_IS_PARTIAL_KEY, false), true);
return Objects.equals(metadata.getOrDefault(A2AMetadataKey.PARTIAL.getType(), false), true);
}

/**
Expand Down Expand Up @@ -110,7 +119,12 @@ private static Optional<Event> handleTaskUpdate(
// append=false, lastChunk=false: emit as partial, reset aggregation
// append=true, lastChunk=true: emit as partial, update aggregation and emit as non-partial
// append=false, lastChunk=true: emit as non-partial, drop aggregation
return Optional.of(eventPart);
return Optional.of(
updateEventMetadata(
eventPart,
artifactEvent.getMetadata(),
artifactEvent.getTaskId(),
artifactEvent.getContextId()));
}

if (updateEvent instanceof TaskStatusUpdateEvent statusEvent) {
Expand All @@ -128,14 +142,21 @@ private static Optional<Event> handleTaskUpdate(
});

if (statusEvent.isFinal()) {
return messageEvent
.map(Event::toBuilder)
.or(() -> Optional.of(remoteAgentEventBuilder(context)))
.map(builder -> builder.turnComplete(true))
.map(builder -> builder.partial(false))
.map(Event.Builder::build);
messageEvent =
messageEvent
.map(Event::toBuilder)
.or(() -> Optional.of(remoteAgentEventBuilder(context)))
.map(builder -> builder.turnComplete(true))
.map(builder -> builder.partial(false))
.map(Event.Builder::build);
}
return messageEvent;
return messageEvent.map(
finalMessageEvent ->
updateEventMetadata(
finalMessageEvent,
statusEvent.getMetadata(),
statusEvent.getTaskId(),
statusEvent.getContextId()));
}
throw new IllegalArgumentException(
"Unsupported TaskUpdateEvent type: " + updateEvent.getClass());
Expand Down Expand Up @@ -163,9 +184,13 @@ public static Event messageToFailedEvent(Message message, InvocationContext invo

/** Converts an A2A message back to ADK events. */
public static Event messageToEvent(Message message, InvocationContext invocationContext) {
return remoteAgentEventBuilder(invocationContext)
.content(fromModelParts(PartConverter.toGenaiParts(message.getParts())))
.build();
return updateEventMetadata(
remoteAgentEventBuilder(invocationContext)
.content(fromModelParts(PartConverter.toGenaiParts(message.getParts())))
.build(),
message.getMetadata(),
message.getTaskId(),
message.getContextId());
}

/**
Expand Down Expand Up @@ -228,7 +253,8 @@ public static Event taskToEvent(Task task, InvocationContext invocationContext)
eventBuilder.longRunningToolIds(longRunningToolIds.build());
}
eventBuilder.turnComplete(isFinal);
return eventBuilder.build();
return updateEventMetadata(
eventBuilder.build(), task.getMetadata(), task.getId(), task.getContextId());
}

private static ImmutableSet<String> getLongRunningToolIds(
Expand All @@ -241,9 +267,7 @@ private static ImmutableSet<String> getLongRunningToolIds(
return Optional.<String>empty();
}
Object isLongRunning =
dataPart
.getMetadata()
.get(PartConverter.A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY);
dataPart.getMetadata().get(A2AMetadataKey.IS_LONG_RUNNING.getType());
if (!Objects.equals(isLongRunning, true)) {
return Optional.<String>empty();
}
Expand All @@ -256,6 +280,77 @@ private static ImmutableSet<String> getLongRunningToolIds(
.collect(toImmutableSet());
}

private static Event updateEventMetadata(
Event event,
@Nullable Map<String, Object> clientMetadata,
@Nullable String taskId,
@Nullable String contextId) {
if (taskId == null || contextId == null) {
logger.warn("Task ID or context ID is null, skipping metadata update.");
return event;
}

if (clientMetadata == null) {
clientMetadata = ImmutableMap.of();
}
Event.Builder eventBuilder = event.toBuilder();
Object groundingMetadata = clientMetadata.get(A2AMetadataKey.GROUNDING_METADATA.getType());
// if groundingMetadata is null, parseMetadata will return null as well.
eventBuilder.groundingMetadata(parseMetadata(groundingMetadata, GroundingMetadata.class));
Object usageMetadata = clientMetadata.get(A2AMetadataKey.USAGE_METADATA.getType());
// if usageMetadata is null, parseMetadata will return null as well.
eventBuilder.usageMetadata(
parseMetadata(usageMetadata, GenerateContentResponseUsageMetadata.class));

ImmutableList.Builder<CustomMetadata> customMetadataList = ImmutableList.builder();
customMetadataList
.add(
CustomMetadata.builder()
.key(AdkMetadataKey.TASK_ID.getType())
.stringValue(taskId)
.build())
.add(
CustomMetadata.builder()
.key(AdkMetadataKey.CONTEXT_ID.getType())
.stringValue(contextId)
.build());
Object customMetadata = clientMetadata.get(A2AMetadataKey.CUSTOM_METADATA.getType());
if (customMetadata != null) {
customMetadataList.addAll(
parseMetadata(customMetadata, new TypeReference<List<CustomMetadata>>() {}));
}
eventBuilder.customMetadata(customMetadataList.build());

Object errorCode = clientMetadata.get(A2AMetadataKey.ERROR_CODE.getType());
eventBuilder.errorCode(parseMetadata(errorCode, FinishReason.class));

return eventBuilder.build();
}

private static <T> @Nullable T parseMetadata(@Nullable Object metadata, Class<T> type) {
try {
if (metadata instanceof String jsonString) {
return objectMapper.readValue(jsonString, type);
} else {
return objectMapper.convertValue(metadata, type);
}
} catch (IllegalArgumentException | JsonProcessingException e) {
throw new IllegalArgumentException("Failed to parse metadata of type " + type, e);
}
}

private static <T> @Nullable T parseMetadata(@Nullable Object metadata, TypeReference<T> type) {
try {
if (metadata instanceof String jsonString) {
return objectMapper.readValue(jsonString, type);
} else {
return objectMapper.convertValue(metadata, type);
}
} catch (IllegalArgumentException | JsonProcessingException e) {
throw new IllegalArgumentException("Failed to parse metadata of type " + type.getType(), e);
}
}

private static Event emptyEvent(InvocationContext invocationContext) {
Event.Builder builder =
Event.builder()
Expand Down
Loading
Loading