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
17 changes: 9 additions & 8 deletions a2a/src/main/java/com/google/adk/a2a/agent/RemoteA2AAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,10 @@
import io.reactivex.rxjava3.core.BackpressureStrategy;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.FlowableEmitter;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.function.BiConsumer;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
Expand Down Expand Up @@ -193,22 +191,25 @@ public boolean isStreaming() {
return streaming;
}

private Message.Builder newA2AMessage(Message.Role role, List<io.a2a.spec.Part<?>> parts) {
return new Message.Builder().messageId(UUID.randomUUID().toString()).role(role).parts(parts);
private Message.Builder newA2AMessage(
InvocationContext invocationContext, Message.Role role, List<io.a2a.spec.Part<?>> parts) {
return new Message.Builder().messageId(invocationContext.newUuid()).role(role).parts(parts);
}

private Message prepareMessage(InvocationContext invocationContext) {
Event userCall = EventConverter.findUserFunctionCall(invocationContext.session().events());
if (userCall != null) {
ImmutableList<io.a2a.spec.Part<?>> parts =
EventConverter.contentToParts(userCall.content(), userCall.partial().orElse(false));
return newA2AMessage(Message.Role.USER, parts)
return newA2AMessage(invocationContext, Message.Role.USER, parts)
.taskId(EventConverter.taskId(userCall))
.contextId(EventConverter.contextId(userCall))
.build();
}
return newA2AMessage(
Message.Role.USER, EventConverter.messagePartsFromContext(invocationContext))
invocationContext,
Message.Role.USER,
EventConverter.messagePartsFromContext(invocationContext))
.build();
}

Expand Down Expand Up @@ -568,11 +569,11 @@ private Event createAggregatedEvent(Content content, @Nullable ClientEvent trigg
}

return Event.builder()
.id(UUID.randomUUID().toString())
.id(invocationContext.newUuid())
.invocationId(invocationContext.invocationId())
.author(agentName)
.content(content)
.timestamp(Instant.now().toEpochMilli())
.timestamp(invocationContext.now().toEpochMilli())
.customMetadata(aggMetadataBuilder.build())
.build();
}
Expand Down
42 changes: 23 additions & 19 deletions a2a/src/main/java/com/google/adk/a2a/converters/EventConverter.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.jspecify.annotations.Nullable;

/** Converter for ADK Events to A2A Messages. */
Expand Down Expand Up @@ -144,29 +143,36 @@ public static ImmutableList<Part<?>> messagePartsFromContext(InvocationContext c
}
List<Event> events = context.session().events();
int lastResponseIndex = -1;
String contextId = "";
for (int i = events.size() - 1; i >= 0; i--) {
Event event = events.get(i);
if (event.author().equals(context.agent().name())) {
lastResponseIndex = i;
contextId = contextId(event);
break;
}
}
ImmutableList.Builder<Part<?>> partsBuilder = ImmutableList.builder();
for (int i = lastResponseIndex + 1; i < events.size(); i++) {
Event event = events.get(i);
Optional<Content> content;
boolean partial;
if (!event.author().equals("user") && !event.author().equals(context.agent().name())) {
event = presentAsUserMessage(event, contextId);
// Another agent's output is rephrased as a complete user message.
content = presentAsUserMessage(event);
partial = false;
} else {
content = event.content();
partial = event.partial().orElse(false);
}
contentToParts(event.content(), event.partial().orElse(false)).forEach(partsBuilder::add);
contentToParts(content, partial).forEach(partsBuilder::add);
}
return partsBuilder.build();
}

private static Event presentAsUserMessage(Event event, String contextId) {
Event.Builder userEvent =
new Event.Builder().id(UUID.randomUUID().toString()).invocationId(contextId).author("user");
/**
* Rephrases the content of an event authored by another agent as a user message, or returns empty
* when the event has no parts to present.
*/
private static Optional<Content> presentAsUserMessage(Event event) {
ImmutableList<com.google.genai.types.Part> parts =
event.content().flatMap(Content::parts).stream()
.flatMap(Collection::stream)
Expand All @@ -176,20 +182,18 @@ private static Event presentAsUserMessage(Event event, String contextId) {
.map(part -> PartConverter.remoteCallAsUserPart(event.author(), part))
.collect(toImmutableList());
if (parts.isEmpty()) {
return userEvent.build();
return Optional.empty();
}
com.google.genai.types.Part forContext =
com.google.genai.types.Part.builder().text("For context:").build();
return userEvent
.content(
Content.builder()
.parts(
ImmutableList.<com.google.genai.types.Part>builder()
.add(forContext)
.addAll(parts)
.build())
.build())
.build();
return Optional.of(
Content.builder()
.parts(
ImmutableList.<com.google.genai.types.Part>builder()
.add(forContext)
.addAll(parts)
.build())
.build());
}

private static String metadataValue(Event event, String key) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,10 @@
import io.a2a.spec.TaskArtifactUpdateEvent;
import io.a2a.spec.TaskState;
import io.a2a.spec.TaskStatusUpdateEvent;
import java.time.Instant;
import java.util.List;
import java.util.Map;
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;
Expand Down Expand Up @@ -402,12 +400,12 @@ private static void logDroppedMetadata(A2AMetadataKey key, Exception e) {
private static Event emptyEvent(InvocationContext invocationContext) {
Event.Builder builder =
Event.builder()
.id(UUID.randomUUID().toString())
.id(invocationContext.newUuid())
.invocationId(invocationContext.invocationId())
.author(invocationContext.agent().name())
.branch(invocationContext.branch().orElse(null))
.content(Content.builder().role("user").parts(ImmutableList.of()).build())
.timestamp(Instant.now().toEpochMilli());
.timestamp(invocationContext.now().toEpochMilli());
return builder.build();
}

Expand All @@ -421,10 +419,10 @@ private static boolean hasParts(Event event) {

private static Event.Builder remoteAgentEventBuilder(InvocationContext invocationContext) {
return Event.builder()
.id(UUID.randomUUID().toString())
.id(invocationContext.newUuid())
.invocationId(invocationContext.invocationId())
.author(invocationContext.agent().name())
.branch(invocationContext.branch().orElse(null))
.timestamp(Instant.now().toEpochMilli());
.timestamp(invocationContext.now().toEpochMilli());
}
}
35 changes: 35 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 @@ -67,6 +67,7 @@
import io.reactivex.rxjava3.core.Maybe;
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
import io.reactivex.rxjava3.subscribers.TestSubscriber;
import java.time.Instant;
import java.util.AbstractList;
import java.util.ArrayList;
import java.util.Collections;
Expand All @@ -76,6 +77,7 @@
import java.util.Objects;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
Expand Down Expand Up @@ -220,6 +222,39 @@ public void runAsync_aggregatesPartialEvents() {
assertResponseMetadata(finalEvent);
}

@Test
@SuppressWarnings("unchecked") // cast for Mockito
public void runAsync_usesInvocationProvidersForMessageIdAndEvents() {
RemoteA2AAgent agent = createAgent();
AtomicInteger counter = new AtomicInteger();
InvocationContext providerContext =
invocationContext.toBuilder()
.instantSource(() -> Instant.ofEpochMilli(1234L))
.uuidProvider(() -> String.format("uuid-%04d", counter.getAndIncrement()))
.build();
mockStreamResponse(
consumer -> {
consumer.accept(createPartialEvent("Hello ", true, false), agentCard);
consumer.accept(createPartialEvent("World!", true, false), agentCard);
consumer.accept(createFinalEvent("Final artifact content"), agentCard);
});

List<Event> events = agent.runAsync(providerContext).toList().blockingGet();

ArgumentCaptor<Message> messageCaptor = ArgumentCaptor.forClass(Message.class);
verify(mockClient)
.sendMessage(messageCaptor.capture(), any(List.class), any(Consumer.class), any());
// The outbound A2A message id is the first id drawn from the invocation's provider: building
// the request parts draws none.
assertThat(messageCaptor.getValue().getMessageId()).isEqualTo("uuid-0000");
assertThat(events).hasSize(4);
assertThat(events.stream().map(Event::id).distinct()).hasSize(4);
for (Event event : events) {
assertThat(event.id()).startsWith("uuid-");
assertThat(event.timestamp()).isEqualTo(1234L);
}
}

@Test
public void runAsync_aggregatesInterleavedFunctionCalls() {
RemoteA2AAgent agent = createAgent();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ public void testMessagePartsFromContext() {
.build(),
Event.builder()
.author("other_agent")
.partial(true)
.content(
Content.builder()
.parts(ImmutableList.of(Part.builder().text("hey").build()))
Expand All @@ -208,12 +209,21 @@ public void testMessagePartsFromContext() {
.session(session)
.sessionService(new InMemorySessionService())
.agent(agent)
// An explicit invocation id keeps build() from drawing one; rephrasing another agent's
// output must draw none.
.invocationId("invocation-id")
.uuidProvider(
() -> {
throw new AssertionError("messagePartsFromContext must not draw ids");
})
.build();
ImmutableList<io.a2a.spec.Part<?>> parts = EventConverter.messagePartsFromContext(ctx);

assertThat(parts).hasSize(2);
assertThat(((TextPart) parts.get(0)).getText()).isEqualTo("For context:");
assertThat(((TextPart) parts.get(1)).getText()).isEqualTo("[other_agent] said: hey");
// Another agent's output is rephrased as a complete message even when its event was partial.
assertThat(parts.get(1).getMetadata()).doesNotContainKey(A2AMetadataKey.PARTIAL.getType());
}

private static final class TestAgent extends BaseAgent {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
import io.a2a.spec.TaskStatusUpdateEvent;
import io.a2a.spec.TextPart;
import io.reactivex.rxjava3.core.Flowable;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
Expand Down Expand Up @@ -140,6 +141,41 @@ public void messageToEvent_convertsMessage() {
assertThat(content.parts().get().get(0).text()).hasValue("test-message");
}

@Test
public void messageToEvent_usesInvocationProvidersForIdAndTimestamp() {
InvocationContext providerContext =
invocationContext.toBuilder()
.instantSource(() -> Instant.ofEpochMilli(1234L))
.uuidProvider(() -> "fixed-uuid")
.build();
Message a2aMessage =
new Message.Builder()
.messageId("msg-1")
.role(Message.Role.USER)
.parts(ImmutableList.of(new TextPart("test-message")))
.build();

Event event = ResponseConverter.messageToEvent(a2aMessage, providerContext);

assertThat(event.id()).isEqualTo("fixed-uuid");
assertThat(event.timestamp()).isEqualTo(1234L);
}

@Test
public void taskToEvent_withNoMessage_emptyEventUsesInvocationProviders() {
InvocationContext providerContext =
invocationContext.toBuilder()
.instantSource(() -> Instant.ofEpochMilli(1234L))
.uuidProvider(() -> "fixed-uuid")
.build();
Task task = testTask().status(new TaskStatus(TaskState.WORKING, null, null)).build();

Event event = ResponseConverter.taskToEvent(task, providerContext);

assertThat(event.id()).isEqualTo("fixed-uuid");
assertThat(event.timestamp()).isEqualTo(1234L);
}

@Test
public void taskToEvent_withArtifacts_returnsEventFromLastArtifact() {
io.a2a.spec.Part<?> a2aPart = new TextPart("Artifact content");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import com.google.adk.events.Event;
import com.google.adk.events.EventActions;
import com.google.adk.platform.UuidProvider;
import com.google.adk.utils.ApiFutureUtils;
import com.google.adk.utils.Constants;
import com.google.api.core.ApiFuture;
Expand All @@ -38,6 +39,7 @@
import io.reactivex.rxjava3.core.Maybe;
import io.reactivex.rxjava3.core.Single;
import java.time.Instant;
import java.time.InstantSource;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
Expand All @@ -64,6 +66,8 @@
public class FirestoreSessionService implements BaseSessionService {
private static final Logger logger = LoggerFactory.getLogger(FirestoreSessionService.class);
private final Firestore firestore;
private final InstantSource instantSource;
private final UuidProvider uuidProvider;
private static final String ROOT_COLLECTION_NAME = Constants.ROOT_COLLECTION_NAME;
private static final String EVENTS_SUBCOLLECTION_NAME = Constants.EVENTS_SUBCOLLECTION_NAME;
private static final String APP_STATE_COLLECTION = Constants.APP_STATE_COLLECTION;
Expand All @@ -81,7 +85,22 @@ public class FirestoreSessionService implements BaseSessionService {

/** Constructor for FirestoreSessionService. */
public FirestoreSessionService(Firestore firestore) {
this.firestore = firestore;
this(firestore, InstantSource.system(), UuidProvider.SYSTEM);
}

/**
* Creates a session service whose generated session IDs and {@code lastUpdateTime} values come
* from the given providers instead of {@link java.util.UUID#randomUUID()} and the wall clock.
*
* @param firestore The Firestore client.
* @param instantSource Supplies the {@code lastUpdateTime} of a newly created session.
* @param uuidProvider Supplies the session ID when {@code createSession} is not given one.
*/
public FirestoreSessionService(
Firestore firestore, InstantSource instantSource, UuidProvider uuidProvider) {
this.firestore = Objects.requireNonNull(firestore, "firestore cannot be null");
this.instantSource = Objects.requireNonNull(instantSource, "instantSource cannot be null");
this.uuidProvider = Objects.requireNonNull(uuidProvider, "uuidProvider cannot be null");
}

/** Gets the sessions collection reference for a given userId. */
Expand Down Expand Up @@ -121,7 +140,7 @@ public Single<Session> createSession(
Optional.ofNullable(sessionId)
.map(String::trim)
.filter(s -> !s.isEmpty())
.orElseGet(() -> UUID.randomUUID().toString());
.orElseGet(uuidProvider::newUuid);

ConcurrentMap<String, Object> initialState =
(state == null) ? new ConcurrentHashMap<>() : new ConcurrentHashMap<>(state);
Expand All @@ -131,7 +150,7 @@ public Single<Session> createSession(
resolvedSessionId,
initialState);
List<Event> initialEvents = new ArrayList<>();
Instant now = Instant.now();
Instant now = instantSource.instant();
Session newSession =
Session.builder(resolvedSessionId)
.appName(appName)
Expand Down
Loading
Loading