diff --git a/a2a/src/main/java/com/google/adk/a2a/agent/RemoteA2AAgent.java b/a2a/src/main/java/com/google/adk/a2a/agent/RemoteA2AAgent.java index bcad60b15..3ce95962c 100644 --- a/a2a/src/main/java/com/google/adk/a2a/agent/RemoteA2AAgent.java +++ b/a2a/src/main/java/com/google/adk/a2a/agent/RemoteA2AAgent.java @@ -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; @@ -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; @@ -197,13 +199,17 @@ private Message.Builder newA2AMessage( } private Message prepareMessage(InvocationContext invocationContext) { - Event userCall = EventConverter.findUserFunctionCall(invocationContext.session().events()); - if (userCall != null) { + List 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> 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( diff --git a/a2a/src/main/java/com/google/adk/a2a/converters/EventConverter.java b/a2a/src/main/java/com/google/adk/a2a/converters/EventConverter.java index df10a9c85..191623907 100644 --- a/a2a/src/main/java/com/google/adk/a2a/converters/EventConverter.java +++ b/a2a/src/main/java/com/google/adk/a2a/converters/EventConverter.java @@ -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; @@ -31,7 +32,10 @@ /** 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() {} @@ -39,26 +43,29 @@ private EventConverter() {} /** * Returns the task ID from the event. * - *

Task ID is stored in the event's custom metadata with the key {@link #ADK_TASK_ID_KEY}. + *

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. * - *

Context ID is stored in the event's custom metadata with the key {@link - * #ADK_CONTEXT_ID_KEY}. + *

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); } /** @@ -196,14 +203,22 @@ private static Optional 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 findMetadata(Event event, String key) { + return event.customMetadata().stream() + .flatMap(List::stream) + .filter(m -> m.key().map(k -> k.equals(key)).orElse(false)) + .findFirst(); + } } diff --git a/a2a/src/test/java/com/google/adk/a2a/agent/RemoteA2AAgentTest.java b/a2a/src/test/java/com/google/adk/a2a/agent/RemoteA2AAgentTest.java index 2bb450003..d11cf3646 100644 --- a/a2a/src/test/java/com/google/adk/a2a/agent/RemoteA2AAgentTest.java +++ b/a2a/src/test/java/com/google/adk/a2a/agent/RemoteA2AAgentTest.java @@ -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; @@ -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 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 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() { diff --git a/a2a/src/test/java/com/google/adk/a2a/converters/EventConverterTest.java b/a2a/src/test/java/com/google/adk/a2a/converters/EventConverterTest.java index 51fc3184f..05445cf52 100644 --- a/a2a/src/test/java/com/google/adk/a2a/converters/EventConverterTest.java +++ b/a2a/src/test/java/com/google/adk/a2a/converters/EventConverterTest.java @@ -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(); @@ -241,4 +270,8 @@ protected Flowable runLiveImpl(InvocationContext invocationContext) { return Flowable.empty(); } } + + private static CustomMetadata metadata(String key, String value) { + return CustomMetadata.builder().key(key).stringValue(value).build(); + } }