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
16 changes: 11 additions & 5 deletions a2a/src/main/java/com/google/adk/a2a/agent/RemoteA2AAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package com.google.adk.a2a.agent;

import static com.google.common.base.Strings.emptyToNull;
import static com.google.common.base.Strings.nullToEmpty;

import com.fasterxml.jackson.core.JsonProcessingException;
Expand All @@ -30,6 +31,7 @@
import com.google.adk.events.Event;
import com.google.adk.utils.AgentEnums.AgentOrigin;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.genai.types.Content;
import com.google.genai.types.CustomMetadata;
Expand Down Expand Up @@ -197,13 +199,17 @@ private Message.Builder newA2AMessage(
}

private Message prepareMessage(InvocationContext invocationContext) {
Event userCall = EventConverter.findUserFunctionCall(invocationContext.session().events());
if (userCall != null) {
List<Event> events = invocationContext.session().events();
Event callEvent = EventConverter.findUserFunctionCall(events);
if (callEvent != null) {
// The last event is the user's answer to the call.
Event answer = Iterables.getLast(events);
ImmutableList<io.a2a.spec.Part<?>> parts =
EventConverter.contentToParts(userCall.content(), userCall.partial().orElse(false));
EventConverter.contentToParts(answer.content(), answer.partial().orElse(false));
// As in ADK Python, a call that came without ids, such as one in a plain message, sends none.
return newA2AMessage(invocationContext, Message.Role.USER, parts)
.taskId(EventConverter.taskId(userCall))
.contextId(EventConverter.contextId(userCall))
.taskId(emptyToNull(EventConverter.taskId(callEvent)))
.contextId(emptyToNull(EventConverter.contextId(callEvent)))
.build();
}
return newA2AMessage(
Expand Down
41 changes: 28 additions & 13 deletions a2a/src/main/java/com/google/adk/a2a/converters/EventConverter.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.genai.types.Content;
import com.google.genai.types.CustomMetadata;
import com.google.genai.types.FunctionResponse;
import io.a2a.spec.Part;
import java.util.Collection;
Expand All @@ -31,34 +32,40 @@

/** Converter for ADK Events to A2A Messages. */
public final class EventConverter {
/** Legacy task ID key, read only for events with neither {@code a2a:} ID key. */
public static final String ADK_TASK_ID_KEY = "adk_task_id";

/** Legacy context ID key, read only for events with neither {@code a2a:} ID key. */
public static final String ADK_CONTEXT_ID_KEY = "adk_context_id";

private EventConverter() {}

/**
* Returns the task ID from the event.
*
* <p>Task ID is stored in the event's custom metadata with the key {@link #ADK_TASK_ID_KEY}.
* <p>Task ID is stored in the event's custom metadata under {@code a2a:task_id}, where {@link
* ResponseConverter} and ADK Python write it. An event with neither {@code a2a:task_id} nor
* {@code a2a:context_id} is read from the legacy {@link #ADK_TASK_ID_KEY}.
*
* @param event The event to get the task ID from.
* @return The task ID, or an empty string if not found.
*/
public static String taskId(Event event) {
return metadataValue(event, ADK_TASK_ID_KEY);
return remoteId(event, AdkMetadataKey.TASK_ID.getType(), ADK_TASK_ID_KEY);
}

/**
* Returns the context ID from the event.
*
* <p>Context ID is stored in the event's custom metadata with the key {@link
* #ADK_CONTEXT_ID_KEY}.
* <p>Context ID is stored in the event's custom metadata under {@code a2a:context_id}, where
* {@link ResponseConverter} and ADK Python write it. An event with neither {@code a2a:task_id}
* nor {@code a2a:context_id} is read from the legacy {@link #ADK_CONTEXT_ID_KEY}.
*
* @param event The event to get the context ID from.
* @return The context ID, or an empty string if not found.
*/
public static String contextId(Event event) {
return metadataValue(event, ADK_CONTEXT_ID_KEY);
return remoteId(event, AdkMetadataKey.CONTEXT_ID.getType(), ADK_CONTEXT_ID_KEY);
}

/**
Expand Down Expand Up @@ -196,14 +203,22 @@ private static Optional<Content> presentAsUserMessage(Event event) {
.build());
}

private static String metadataValue(Event event, String key) {
if (event.customMetadata().isEmpty()) {
return "";
}
return event.customMetadata().get().stream()
.filter(m -> m.key().map(k -> k.equals(key)).orElse(false))
.findFirst()
.flatMap(m -> m.stringValue())
/**
* Returns the ID under {@code key}, or under {@code legacyKey} if the event has neither new key.
*/
private static String remoteId(Event event, String key, String legacyKey) {
boolean hasNewKeys =
findMetadata(event, AdkMetadataKey.TASK_ID.getType()).isPresent()
|| findMetadata(event, AdkMetadataKey.CONTEXT_ID.getType()).isPresent();
return findMetadata(event, hasNewKeys ? key : legacyKey)
.flatMap(CustomMetadata::stringValue)
.orElse("");
}

private static Optional<CustomMetadata> findMetadata(Event event, String key) {
return event.customMetadata().stream()
.flatMap(List::stream)
.filter(m -> m.key().map(k -> k.equals(key)).orElse(false))
.findFirst();
}
}
99 changes: 99 additions & 0 deletions a2a/src/test/java/com/google/adk/a2a/agent/RemoteA2AAgentTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@
import com.google.adk.sessions.Session;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.genai.types.Content;
import com.google.genai.types.CustomMetadata;
import com.google.genai.types.FunctionCall;
import com.google.genai.types.FunctionResponse;
import com.google.genai.types.Part;
import io.a2a.client.Client;
Expand Down Expand Up @@ -652,6 +654,103 @@ public void runAsync_constructsRequestWithHistory() {
assertThat(((TextPart) message.getParts().get(3)).getText()).isEqualTo("how are you?");
}

@Test
public void runAsync_answeringRemoteCall_sendsAnswerInItsTask() {
// Recorded under the keys ResponseConverter writes.
Message message =
sendAnswerToRemoteCall(
ImmutableList.of(
CustomMetadata.builder().key("a2a:task_id").stringValue("task-1").build(),
CustomMetadata.builder().key("a2a:context_id").stringValue("context-1").build()));

assertThat(message.getTaskId()).isEqualTo("task-1");
assertThat(message.getContextId()).isEqualTo("context-1");
DataPart answer = (DataPart) Iterables.getOnlyElement(message.getParts());
assertThat(answer.getMetadata()).containsEntry("adk_type", "function_response");
assertThat(answer.getData()).containsEntry("id", "call-1");
}

@Test
public void runAsync_answeringRemoteCallWithoutIds_sendsNoIds() {
Message message = sendAnswerToRemoteCall(ImmutableList.of());

assertThat(message.getTaskId()).isNull();
assertThat(message.getContextId()).isNull();
}

/**
* Runs the agent on a session where the user answers a remote call recorded with {@code
* callMetadata}, and returns the message sent.
*/
@SuppressWarnings("unchecked") // cast for Mockito
private Message sendAnswerToRemoteCall(ImmutableList<CustomMetadata> callMetadata) {
RemoteA2AAgent agent = createAgent();
Session session =
Session.builder("session-4")
.appName("demo")
.userId("user")
.events(
ImmutableList.of(
Event.builder()
.id("e1")
.author("remote-agent")
.content(
Content.builder()
.role("model")
.parts(
ImmutableList.of(
Part.builder()
.functionCall(
FunctionCall.builder()
.name("fn")
.id("call-1")
.build())
// As converted from the remote's function-call part.
.partMetadata(
ImmutableMap.of("adk_type", "function_call"))
.build()))
.build())
.customMetadata(callMetadata)
.build(),
Event.builder()
.id("e2")
.author("user")
.content(
Content.builder()
.role("user")
.parts(
ImmutableList.of(
Part.builder()
.functionResponse(
FunctionResponse.builder()
.name("fn")
.id("call-1")
.response(ImmutableMap.of("result", "ok"))
.build())
.build()))
.build())
.build()))
.build();
InvocationContext context =
InvocationContext.builder()
.sessionService(new InMemorySessionService())
.artifactService(new InMemoryArtifactService())
.pluginManager(new PluginManager())
.invocationId("invocation-4")
.agent(new TestAgent())
.session(session)
.runConfig(RunConfig.builder().build())
.build();
mockStreamResponse(consumer -> consumer.accept(createFinalEvent("ok"), agentCard));

var unused = agent.runAsync(context).toList().blockingGet();

ArgumentCaptor<Message> messageCaptor = ArgumentCaptor.forClass(Message.class);
verify(mockClient)
.sendMessage(messageCaptor.capture(), any(List.class), any(Consumer.class), any());
return messageCaptor.getValue();
}

@Test
@SuppressWarnings("unchecked") // cast for Mockito
public void runAsync_constructsRequestWithFunctionResponse() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,35 @@ public void testTaskId() {
assertThat(EventConverter.taskId(e)).isEqualTo("task-123");
}

@Test
public void taskIdAndContextId_readA2aKeysBeforeLegacyKeys() {
Event e =
Event.builder()
.customMetadata(
ImmutableList.of(
metadata("a2a:task_id", "task-1"),
metadata("a2a:context_id", "context-1"),
metadata(EventConverter.ADK_TASK_ID_KEY, "legacy-task"),
metadata(EventConverter.ADK_CONTEXT_ID_KEY, "legacy-context")))
.build();

assertThat(EventConverter.taskId(e)).isEqualTo("task-1");
assertThat(EventConverter.contextId(e)).isEqualTo("context-1");
}

@Test
public void taskId_eventWithA2aContextIdOnly_ignoresLegacyTaskId() {
Event e =
Event.builder()
.customMetadata(
ImmutableList.of(
metadata("a2a:context_id", "context-1"),
metadata(EventConverter.ADK_TASK_ID_KEY, "legacy-task")))
.build();

assertThat(EventConverter.taskId(e)).isEmpty();
}

@Test
public void testTaskId_empty() {
Event e = Event.builder().build();
Expand Down Expand Up @@ -241,4 +270,8 @@ protected Flowable<Event> runLiveImpl(InvocationContext invocationContext) {
return Flowable.empty();
}
}

private static CustomMetadata metadata(String key, String value) {
return CustomMetadata.builder().key(key).stringValue(value).build();
}
}
Loading