Skip to content

Commit c3b4a75

Browse files
authored
Merge branch 'main' into ISSUE-777
2 parents 8591f80 + aabf15a commit c3b4a75

28 files changed

Lines changed: 818 additions & 534 deletions

File tree

a2a/src/main/java/com/google/adk/a2a/agent/RemoteA2AAgent.java

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,25 @@ public RemoteA2AAgent build() {
181181
}
182182
}
183183

184+
private Message.Builder newA2AMessage(Message.Role role, List<io.a2a.spec.Part<?>> parts) {
185+
return new Message.Builder().messageId(UUID.randomUUID().toString()).role(role).parts(parts);
186+
}
187+
188+
private Message prepareMessage(InvocationContext invocationContext) {
189+
Event userCall = EventConverter.findUserFunctionCall(invocationContext.session().events());
190+
if (userCall != null) {
191+
ImmutableList<io.a2a.spec.Part<?>> parts =
192+
EventConverter.contentToParts(userCall.content(), userCall.partial().orElse(false));
193+
return newA2AMessage(Message.Role.USER, parts)
194+
.taskId(EventConverter.taskId(userCall))
195+
.contextId(EventConverter.contextId(userCall))
196+
.build();
197+
}
198+
return newA2AMessage(
199+
Message.Role.USER, EventConverter.messagePartsFromContext(invocationContext))
200+
.build();
201+
}
202+
184203
@Override
185204
protected Flowable<Event> runAsyncImpl(InvocationContext invocationContext) {
186205
// Construct A2A Message from the last ADK event
@@ -191,14 +210,7 @@ protected Flowable<Event> runAsyncImpl(InvocationContext invocationContext) {
191210
return Flowable.empty();
192211
}
193212

194-
Optional<Message> a2aMessageOpt = EventConverter.convertEventsToA2AMessage(invocationContext);
195-
196-
if (a2aMessageOpt.isEmpty()) {
197-
logger.warn("Failed to convert event to A2A message.");
198-
return Flowable.empty();
199-
}
200-
201-
Message originalMessage = a2aMessageOpt.get();
213+
Message originalMessage = prepareMessage(invocationContext);
202214
String requestJson = serializeMessageToJson(originalMessage);
203215

204216
return Flowable.create(

a2a/src/main/java/com/google/adk/a2a/converters/EventConverter.java

Lines changed: 154 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -18,71 +18,188 @@
1818
import static com.google.common.collect.ImmutableList.toImmutableList;
1919

2020
import com.google.adk.agents.InvocationContext;
21+
import com.google.adk.events.Event;
2122
import com.google.common.collect.ImmutableList;
23+
import com.google.common.collect.Iterables;
2224
import com.google.genai.types.Content;
23-
import io.a2a.spec.Message;
25+
import com.google.genai.types.FunctionResponse;
2426
import io.a2a.spec.Part;
2527
import java.util.Collection;
28+
import java.util.List;
2629
import java.util.Optional;
2730
import java.util.UUID;
28-
import org.slf4j.Logger;
29-
import org.slf4j.LoggerFactory;
31+
import org.jspecify.annotations.Nullable;
3032

3133
/** Converter for ADK Events to A2A Messages. */
3234
public final class EventConverter {
33-
private static final Logger logger = LoggerFactory.getLogger(EventConverter.class);
35+
public static final String ADK_TASK_ID_KEY = "adk_task_id";
36+
public static final String ADK_CONTEXT_ID_KEY = "adk_context_id";
3437

3538
private EventConverter() {}
3639

3740
/**
38-
* Converts an ADK InvocationContext to an A2A Message.
41+
* Returns the task ID from the event.
3942
*
40-
* <p>It combines all the events in the session, plus the user content, converted into A2A Parts,
41-
* into a single A2A Message.
43+
* <p>Task ID is stored in the event's custom metadata with the key {@link #ADK_TASK_ID_KEY}.
4244
*
43-
* <p>If the context has no events, or no suitable content to build the message, an empty optional
44-
* is returned.
45-
*
46-
* @param context The ADK InvocationContext to convert.
47-
* @return The converted A2A Message.
45+
* @param event The event to get the task ID from.
46+
* @return The task ID, or an empty string if not found.
4847
*/
49-
public static Optional<Message> convertEventsToA2AMessage(InvocationContext context) {
50-
if (context.session().events().isEmpty()) {
51-
logger.warn("No events in session, cannot convert to A2A message.");
52-
return Optional.empty();
53-
}
54-
55-
ImmutableList.Builder<Part<?>> partsBuilder = ImmutableList.builder();
48+
public static String taskId(Event event) {
49+
return metadataValue(event, ADK_TASK_ID_KEY);
50+
}
5651

57-
context
58-
.session()
59-
.events()
60-
.forEach(
61-
event ->
62-
partsBuilder.addAll(
63-
contentToParts(event.content(), event.partial().orElse(false))));
64-
partsBuilder.addAll(contentToParts(context.userContent(), false));
52+
/**
53+
* Returns the context ID from the event.
54+
*
55+
* <p>Context ID is stored in the event's custom metadata with the key {@link
56+
* #ADK_CONTEXT_ID_KEY}.
57+
*
58+
* @param event The event to get the context ID from.
59+
* @return The context ID, or an empty string if not found.
60+
*/
61+
public static String contextId(Event event) {
62+
return metadataValue(event, ADK_CONTEXT_ID_KEY);
63+
}
6564

66-
ImmutableList<Part<?>> parts = partsBuilder.build();
65+
/**
66+
* Returns the last user function call event from the list of events.
67+
*
68+
* @param events The list of events to find the user function call event from.
69+
* @return The user function call event, or null if not found.
70+
*/
71+
public static @Nullable Event findUserFunctionCall(List<Event> events) {
72+
Event candidate = Iterables.getLast(events);
73+
if (!candidate.author().equals("user")) {
74+
return null;
75+
}
76+
FunctionResponse functionResponse = findUserFunctionResponse(candidate);
77+
if (functionResponse == null || functionResponse.id().isEmpty()) {
78+
return null;
79+
}
80+
for (int i = events.size() - 2; i >= 0; i--) {
81+
Event event = events.get(i);
82+
if (isUserFunctionCall(event, functionResponse.id().get())) {
83+
return event;
84+
}
85+
}
86+
return null;
87+
}
6788

68-
if (parts.isEmpty()) {
69-
logger.warn("No suitable content found to build A2A request message.");
70-
return Optional.empty();
89+
private static @Nullable FunctionResponse findUserFunctionResponse(Event candidate) {
90+
if (candidate.content().isEmpty() || candidate.content().get().parts().isEmpty()) {
91+
return null;
7192
}
93+
return candidate.content().get().parts().get().stream()
94+
.filter(part -> part.functionResponse().isPresent())
95+
.findFirst()
96+
.map(part -> part.functionResponse().get())
97+
.orElse(null);
98+
}
7299

73-
return Optional.of(
74-
new Message.Builder()
75-
.messageId(UUID.randomUUID().toString())
76-
.parts(parts)
77-
.role(Message.Role.USER)
78-
.build());
100+
private static boolean isUserFunctionCall(Event event, String functionResponseId) {
101+
if (event.content().isEmpty()) {
102+
return false;
103+
}
104+
return event.content().get().parts().get().stream()
105+
.anyMatch(
106+
part ->
107+
part.functionCall().isPresent()
108+
&& part.functionCall()
109+
.get()
110+
.id()
111+
.map(id -> id.equals(functionResponseId))
112+
.orElse(false));
79113
}
80114

115+
/**
116+
* Converts a GenAI Content object to a list of A2A Parts.
117+
*
118+
* @param content The GenAI Content object to convert.
119+
* @param isPartial Whether the content is partial.
120+
* @return A list of A2A Parts.
121+
*/
81122
public static ImmutableList<Part<?>> contentToParts(
82123
Optional<Content> content, boolean isPartial) {
83124
return content.flatMap(Content::parts).stream()
84125
.flatMap(Collection::stream)
85126
.map(part -> PartConverter.fromGenaiPart(part, isPartial))
86127
.collect(toImmutableList());
87128
}
129+
130+
/**
131+
* Returns the parts from the context events that should be sent to the agent.
132+
*
133+
* <p>All session events from the previous remote agent response (or the beginning of the session
134+
* in case of the first agent invocation) are included into the A2A message. Events from other
135+
* agents are presented as user messages and rephased as if a user was telling what happened in
136+
* the session up to the point.
137+
*
138+
* @param context The invocation context to get the parts from.
139+
* @return A list of A2A Parts.
140+
*/
141+
public static ImmutableList<Part<?>> messagePartsFromContext(InvocationContext context) {
142+
if (context.session().events().isEmpty()) {
143+
return ImmutableList.of();
144+
}
145+
List<Event> events = context.session().events();
146+
int lastResponseIndex = -1;
147+
String contextId = "";
148+
for (int i = events.size() - 1; i >= 0; i--) {
149+
Event event = events.get(i);
150+
if (event.author().equals(context.agent().name())) {
151+
lastResponseIndex = i;
152+
contextId = contextId(event);
153+
break;
154+
}
155+
}
156+
ImmutableList.Builder<Part<?>> partsBuilder = ImmutableList.builder();
157+
for (int i = lastResponseIndex + 1; i < events.size(); i++) {
158+
Event event = events.get(i);
159+
if (!event.author().equals("user") && !event.author().equals(context.agent().name())) {
160+
event = presentAsUserMessage(event, contextId);
161+
}
162+
contentToParts(event.content(), event.partial().orElse(false)).forEach(partsBuilder::add);
163+
}
164+
return partsBuilder.build();
165+
}
166+
167+
private static Event presentAsUserMessage(Event event, String contextId) {
168+
Event.Builder userEvent =
169+
new Event.Builder().id(UUID.randomUUID().toString()).invocationId(contextId).author("user");
170+
ImmutableList<com.google.genai.types.Part> parts =
171+
event.content().flatMap(Content::parts).stream()
172+
.flatMap(Collection::stream)
173+
// convert only non-thought parts to user message parts, skip thought parts as they are
174+
// not meant to be shown to the user
175+
.filter(part -> !part.thought().orElse(false))
176+
.map(part -> PartConverter.remoteCallAsUserPart(event.author(), part))
177+
.collect(toImmutableList());
178+
if (parts.isEmpty()) {
179+
return userEvent.build();
180+
}
181+
com.google.genai.types.Part forContext =
182+
com.google.genai.types.Part.builder().text("For context:").build();
183+
return userEvent
184+
.content(
185+
Content.builder()
186+
.parts(
187+
ImmutableList.<com.google.genai.types.Part>builder()
188+
.add(forContext)
189+
.addAll(parts)
190+
.build())
191+
.build())
192+
.build();
193+
}
194+
195+
private static String metadataValue(Event event, String key) {
196+
if (event.customMetadata().isEmpty()) {
197+
return "";
198+
}
199+
return event.customMetadata().get().stream()
200+
.filter(m -> m.key().map(k -> k.equals(key)).orElse(false))
201+
.findFirst()
202+
.flatMap(m -> m.stringValue())
203+
.orElse("");
204+
}
88205
}

a2a/src/main/java/com/google/adk/a2a/converters/PartConverter.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,50 @@ private static FilePart filePartToA2A(Part part, ImmutableMap.Builder<String, Ob
384384
metadata.buildOrThrow());
385385
}
386386

387+
/**
388+
* Converts a remote call part to a user part.
389+
*
390+
* <p>Events are rephrased as if a user was telling what happened in the session up to the point.
391+
* E.g.
392+
*
393+
* <pre>{@code
394+
* For context:
395+
* User said: Now help me with Z
396+
* Agent A said: Agent B can help you with it!
397+
* Agent B said: Agent C might know better.*
398+
* }</pre>
399+
*
400+
* @param author The author of the part.
401+
* @param part The part to convert.
402+
* @return The converted part.
403+
*/
404+
public static Part remoteCallAsUserPart(String author, Part part) {
405+
if (part.text().isPresent()) {
406+
String partText = String.format("[%s] said: %s", author, part.text().get());
407+
return Part.builder().text(partText).build();
408+
} else if (part.functionCall().isPresent()) {
409+
FunctionCall functionCall = part.functionCall().get();
410+
String partText =
411+
String.format(
412+
"[%s] called tool %s with parameters: %s",
413+
author,
414+
functionCall.name().orElse("<unknown>"),
415+
functionCall.args().orElse(ImmutableMap.of()));
416+
return Part.builder().text(partText).build();
417+
} else if (part.functionResponse().isPresent()) {
418+
FunctionResponse functionResponse = part.functionResponse().get();
419+
String partText =
420+
String.format(
421+
"[%s] %s tool returned result: %s",
422+
author,
423+
functionResponse.name().orElse("<unknown>"),
424+
functionResponse.response().orElse(ImmutableMap.of()));
425+
return Part.builder().text(partText).build();
426+
} else {
427+
return part;
428+
}
429+
}
430+
387431
@SuppressWarnings("unchecked") // safe conversion from objectMapper.readValue
388432
private static Map<String, Object> coerceToMap(Object value) {
389433
if (value == null) {

a2a/src/test/java/com/google/adk/a2a/agent/RemoteA2AAgentTest.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -412,10 +412,11 @@ public void runAsync_constructsRequestWithHistory() {
412412
.sendMessage(messageCaptor.capture(), any(List.class), any(Consumer.class), any());
413413
Message message = messageCaptor.getValue();
414414
assertThat(message.getRole()).isEqualTo(Message.Role.USER);
415-
assertThat(message.getParts()).hasSize(3);
415+
assertThat(message.getParts()).hasSize(4);
416416
assertThat(((TextPart) message.getParts().get(0)).getText()).isEqualTo("hello");
417-
assertThat(((TextPart) message.getParts().get(1)).getText()).isEqualTo("hi");
418-
assertThat(((TextPart) message.getParts().get(2)).getText()).isEqualTo("how are you?");
417+
assertThat(((TextPart) message.getParts().get(1)).getText()).isEqualTo("For context:");
418+
assertThat(((TextPart) message.getParts().get(2)).getText()).isEqualTo("[model] said: hi");
419+
assertThat(((TextPart) message.getParts().get(3)).getText()).isEqualTo("how are you?");
419420
}
420421

421422
@Test

0 commit comments

Comments
 (0)