Skip to content

Commit d97debf

Browse files
MiloszSobczykcopybara-github
authored andcommitted
fix(a2a): skip an unconvertible remote A2A part instead of failing the turn
PiperOrigin-RevId: 990173616
1 parent 21c8fa1 commit d97debf

3 files changed

Lines changed: 274 additions & 57 deletions

File tree

‎a2a/src/main/java/com/google/adk/a2a/converters/ResponseConverter.java‎

Lines changed: 64 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@
1616
package com.google.adk.a2a.converters;
1717

1818
import static com.google.common.collect.ImmutableList.toImmutableList;
19-
import static com.google.common.collect.ImmutableSet.toImmutableSet;
20-
import static com.google.common.collect.Streams.zip;
2119

2220
import com.fasterxml.jackson.core.JsonProcessingException;
2321
import com.fasterxml.jackson.databind.JavaType;
@@ -31,6 +29,7 @@
3129
import com.google.genai.types.Content;
3230
import com.google.genai.types.CustomMetadata;
3331
import com.google.genai.types.FinishReason;
32+
import com.google.genai.types.FunctionCall;
3433
import com.google.genai.types.GenerateContentResponseUsageMetadata;
3534
import com.google.genai.types.GroundingMetadata;
3635
import com.google.genai.types.Part;
@@ -55,7 +54,12 @@
5554
import org.slf4j.Logger;
5655
import org.slf4j.LoggerFactory;
5756

58-
/** Utility for converting ADK events to A2A spec messages (and back). */
57+
/**
58+
* Utility for converting ADK events to A2A spec messages (and back).
59+
*
60+
* <p>Unparseable ADK metadata and unconvertible parts from a remote agent are logged and dropped;
61+
* the rest of the event is still converted.
62+
*/
5963
public final class ResponseConverter {
6064
private static final ObjectMapper objectMapper = new ObjectMapper();
6165
private static final Logger logger = LoggerFactory.getLogger(ResponseConverter.class);
@@ -69,16 +73,16 @@ private ResponseConverter() {}
6973
/**
7074
* Converts a A2A {@link ClientEvent} to an ADK {@link Event}, based on the event type. Returns an
7175
* empty optional if the event should be ignored (e.g. if the event is not a final update for
72-
* TaskArtifactUpdateEvent or if the message is empty for TaskStatusUpdateEvent).
73-
*
74-
* <p>Unparseable ADK metadata is logged and dropped; the rest of the event is still converted.
76+
* TaskArtifactUpdateEvent, if the message is empty for TaskStatusUpdateEvent, or if no part could
77+
* be converted from a message or from the message of a status update that does not end the turn).
7578
*
7679
* @throws IllegalArgumentException if the event type is not supported.
7780
*/
7881
public static Optional<Event> clientEventToEvent(
7982
ClientEvent event, InvocationContext invocationContext) {
8083
if (event instanceof MessageEvent messageEvent) {
81-
return Optional.of(messageToEvent(messageEvent.getMessage(), invocationContext));
84+
return Optional.of(messageToEvent(messageEvent.getMessage(), invocationContext))
85+
.filter(ResponseConverter::hasParts);
8286
} else if (event instanceof TaskEvent taskEvent) {
8387
return Optional.of(taskToEvent(taskEvent.getTask(), invocationContext));
8488
} else if (event instanceof TaskUpdateEvent updateEvent) {
@@ -101,9 +105,10 @@ private static boolean isLongRunning(@Nullable Map<String, Object> metadata) {
101105
}
102106

103107
/**
104-
* Converts a A2A {@link TaskUpdateEvent} to an ADK {@link Event}, if applicable. Returns null if
105-
* the event is not a final update for TaskArtifactUpdateEvent or if the message is empty for
106-
* TaskStatusUpdateEvent.
108+
* Converts a A2A {@link TaskUpdateEvent} to an ADK {@link Event}, if applicable. Returns empty if
109+
* the event is not a final update for TaskArtifactUpdateEvent, if the message is empty for
110+
* TaskStatusUpdateEvent, or if no part could be converted from the message of a status update
111+
* that does not end the turn.
107112
*
108113
* @throws IllegalArgumentException if the task update type is not supported.
109114
*/
@@ -120,7 +125,7 @@ private static Optional<Event> handleTaskUpdate(
120125
}
121126

122127
Event eventPart = artifactToEvent(artifactEvent.getArtifact(), context);
123-
if (eventPart.content().flatMap(Content::parts).orElse(ImmutableList.of()).isEmpty()) {
128+
if (!hasParts(eventPart)) {
124129
return Optional.empty();
125130
}
126131
eventPart.setPartial(isAppend || !isLastChunk);
@@ -148,7 +153,8 @@ private static Optional<Event> handleTaskUpdate(
148153
return messageToFailedEvent(value, context);
149154
}
150155
return messageToEvent(value, context, PENDING_STATES.contains(taskState));
151-
});
156+
})
157+
.filter(converted -> taskState == TaskState.FAILED || hasParts(converted));
152158

153159
if (statusEvent.isFinal()
154160
|| taskState == TaskState.INPUT_REQUIRED
@@ -176,10 +182,10 @@ private static Optional<Event> handleTaskUpdate(
176182
/** Converts an artifact to an ADK event. */
177183
public static Event artifactToEvent(Artifact artifact, InvocationContext invocationContext) {
178184
Event.Builder eventBuilder = remoteAgentEventBuilder(invocationContext);
179-
ImmutableList<Part> genaiParts = PartConverter.toGenaiParts(artifact.parts());
185+
ConvertedParts converted = convertParts(artifact.parts());
180186
eventBuilder
181-
.content(fromModelParts(genaiParts))
182-
.longRunningToolIds(getLongRunningToolIds(artifact.parts(), genaiParts));
187+
.content(fromModelParts(converted.parts()))
188+
.longRunningToolIds(converted.longRunningToolIds());
183189
return eventBuilder.build();
184190
}
185191

@@ -193,15 +199,11 @@ public static Event messageToFailedEvent(Message message, InvocationContext invo
193199
return builder.build();
194200
}
195201

196-
/**
197-
* Converts an A2A message back to ADK events.
198-
*
199-
* <p>Unparseable ADK metadata is logged and dropped; the rest of the event is still converted.
200-
*/
202+
/** Converts an A2A message back to ADK events. */
201203
public static Event messageToEvent(Message message, InvocationContext invocationContext) {
202204
return updateEventMetadata(
203205
remoteAgentEventBuilder(invocationContext)
204-
.content(fromModelParts(PartConverter.toGenaiParts(message.getParts())))
206+
.content(fromModelParts(convertParts(message.getParts()).parts()))
205207
.build(),
206208
message.getMetadata(),
207209
message.getTaskId(),
@@ -216,7 +218,7 @@ public static Event messageToEvent(
216218
Message message, InvocationContext invocationContext, boolean isPending) {
217219

218220
ImmutableList<Part> genaiParts =
219-
PartConverter.toGenaiParts(message.getParts()).stream()
221+
convertParts(message.getParts()).parts().stream()
220222
.map(part -> part.toBuilder().thought(isPending).build())
221223
.collect(toImmutableList());
222224

@@ -227,26 +229,23 @@ public static Event messageToEvent(
227229
* Converts an A2A {@link Task} to an ADK {@link Event}. If the artifacts are present, the last
228230
* artifact is used. If not, the status message is used. If not, the last history message is used.
229231
* If none of these are present, an empty event is returned.
230-
*
231-
* <p>Unparseable ADK metadata is logged and dropped; the rest of the event is still converted.
232232
*/
233233
public static Event taskToEvent(Task task, InvocationContext invocationContext) {
234234
ImmutableList.Builder<Part> genaiParts = ImmutableList.builder();
235235
ImmutableSet.Builder<String> longRunningToolIds = ImmutableSet.builder();
236236

237237
for (Artifact artifact : task.getArtifacts()) {
238-
ImmutableList<Part> converted = PartConverter.toGenaiParts(artifact.parts());
239-
longRunningToolIds.addAll(getLongRunningToolIds(artifact.parts(), converted));
240-
genaiParts.addAll(converted);
238+
ConvertedParts converted = convertParts(artifact.parts());
239+
longRunningToolIds.addAll(converted.longRunningToolIds());
240+
genaiParts.addAll(converted.parts());
241241
}
242242

243243
Event.Builder eventBuilder = remoteAgentEventBuilder(invocationContext);
244244

245245
if (task.getStatus().message() != null) {
246-
ImmutableList<Part> msgParts =
247-
PartConverter.toGenaiParts(task.getStatus().message().getParts());
248-
longRunningToolIds.addAll(
249-
getLongRunningToolIds(task.getStatus().message().getParts(), msgParts));
246+
ConvertedParts convertedMessage = convertParts(task.getStatus().message().getParts());
247+
ImmutableList<Part> msgParts = convertedMessage.parts();
248+
longRunningToolIds.addAll(convertedMessage.longRunningToolIds());
250249
if (task.getStatus().state() == TaskState.FAILED
251250
&& msgParts.size() == 1
252251
&& msgParts.get(0).text().isPresent()) {
@@ -277,26 +276,36 @@ public static Event taskToEvent(Task task, InvocationContext invocationContext)
277276
eventBuilder.build(), task.getMetadata(), task.getId(), task.getContextId());
278277
}
279278

280-
private static ImmutableSet<String> getLongRunningToolIds(
281-
List<io.a2a.spec.Part<?>> parts, List<Part> convertedParts) {
282-
return zip(
283-
parts.stream(),
284-
convertedParts.stream(),
285-
(part, convertedPart) -> {
286-
if (!(part instanceof DataPart dataPart)) {
287-
return Optional.<String>empty();
288-
}
289-
// A2A peers may omit metadata entirely, which deserializes to null.
290-
if (!isLongRunning(dataPart.getMetadata())) {
291-
return Optional.<String>empty();
292-
}
293-
if (convertedPart.functionCall().isEmpty()) {
294-
return Optional.<String>empty();
295-
}
296-
return convertedPart.functionCall().get().id();
297-
})
298-
.flatMap(Optional::stream)
299-
.collect(toImmutableSet());
279+
/** GenAI parts converted from A2A parts, and the ids of the long-running calls among them. */
280+
private record ConvertedParts(
281+
ImmutableList<Part> parts, ImmutableSet<String> longRunningToolIds) {}
282+
283+
/**
284+
* Converts peer-supplied parts, skipping any part that cannot be converted so that one malformed
285+
* part does not fail the whole event.
286+
*/
287+
private static ConvertedParts convertParts(List<io.a2a.spec.Part<?>> a2aParts) {
288+
ImmutableList.Builder<Part> parts = ImmutableList.builder();
289+
ImmutableSet.Builder<String> longRunningToolIds = ImmutableSet.builder();
290+
for (io.a2a.spec.Part<?> a2aPart : a2aParts) {
291+
Part part;
292+
try {
293+
part = PartConverter.toGenaiPart(a2aPart);
294+
} catch (RuntimeException e) {
295+
// The exception message can quote the peer's bytes, so log only the types.
296+
logger.warn(
297+
"Skipping A2A part that could not be converted: {} ({})",
298+
a2aPart == null ? "null" : a2aPart.getClass().getSimpleName(),
299+
e.getClass().getSimpleName());
300+
continue;
301+
}
302+
parts.add(part);
303+
// A2A peers may omit metadata entirely, which deserializes to null.
304+
if (a2aPart instanceof DataPart dataPart && isLongRunning(dataPart.getMetadata())) {
305+
part.functionCall().flatMap(FunctionCall::id).ifPresent(longRunningToolIds::add);
306+
}
307+
}
308+
return new ConvertedParts(parts.build(), longRunningToolIds.build());
300309
}
301310

302311
private static Event updateEventMetadata(
@@ -406,6 +415,10 @@ private static Content fromModelParts(List<Part> parts) {
406415
return Content.builder().role("model").parts(parts).build();
407416
}
408417

418+
private static boolean hasParts(Event event) {
419+
return !event.content().flatMap(Content::parts).orElse(ImmutableList.of()).isEmpty();
420+
}
421+
409422
private static Event.Builder remoteAgentEventBuilder(InvocationContext invocationContext) {
410423
return Event.builder()
411424
.id(UUID.randomUUID().toString())

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

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
import io.reactivex.rxjava3.core.Maybe;
6868
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
6969
import io.reactivex.rxjava3.subscribers.TestSubscriber;
70+
import java.util.AbstractList;
7071
import java.util.ArrayList;
7172
import java.util.Collections;
7273
import java.util.HashMap;
@@ -153,6 +154,44 @@ public void createAgent_streaming_true_returnsStreamingAgent() {
153154
assertThat(agent.isStreaming()).isTrue();
154155
}
155156

157+
@Test
158+
public void runAsync_withUnconvertiblePartInStream_skipsItAndCompletes() {
159+
RemoteA2AAgent agent = createAgent();
160+
mockStreamResponse(
161+
consumer -> {
162+
consumer.accept(createPartialEvent("Hello", true, false), agentCard);
163+
TaskStatus working =
164+
new TaskStatus(
165+
TaskState.WORKING,
166+
new Message.Builder()
167+
.role(Message.Role.AGENT)
168+
.parts(
169+
ImmutableList.of(
170+
new FilePart(new FileWithBytes("text/plain", "bad.txt", "!!!"))))
171+
.build(),
172+
null);
173+
consumer.accept(
174+
new TaskUpdateEvent(
175+
new Task.Builder().id("task-1").contextId("context-1").status(working).build(),
176+
new TaskStatusUpdateEvent.Builder()
177+
.taskId("task-1")
178+
.contextId("context-1")
179+
.status(working)
180+
.isFinal(false)
181+
.build()),
182+
agentCard);
183+
consumer.accept(createFinalEvent("Done"), agentCard);
184+
});
185+
186+
List<Event> events = agent.runAsync(invocationContext).toList().blockingGet();
187+
188+
assertThat(events).hasSize(3);
189+
assertText(events.get(0), "Hello");
190+
assertText(events.get(1), "Hello");
191+
assertAggregated(events.get(1));
192+
assertText(events.get(2), "Done");
193+
}
194+
156195
@Test
157196
public void runAsync_aggregatesPartialEvents() {
158197
RemoteA2AAgent agent = createAgent();
@@ -1060,13 +1099,25 @@ private RemoteA2AAgent createAgent() {
10601099
return getAgentBuilder().streaming(true).build();
10611100
}
10621101

1063-
/** An event whose file part carries invalid base64, so {@code PartConverter} cannot decode it. */
1102+
/**
1103+
* An event whose conversion throws. Conversion skips malformed peer parts and metadata, so the
1104+
* failure is injected through a parts list that throws when read.
1105+
*/
10641106
private ClientEvent unconvertibleEvent() {
1065-
return createTestEvent(
1066-
new FilePart(new FileWithBytes("text/plain", "bad.txt", "!!!")),
1067-
TaskState.WORKING,
1068-
true,
1069-
false);
1107+
List<io.a2a.spec.Part<?>> unreadableParts =
1108+
new AbstractList<>() {
1109+
@Override
1110+
public io.a2a.spec.Part<?> get(int index) {
1111+
throw new IllegalArgumentException("injected");
1112+
}
1113+
1114+
@Override
1115+
public int size() {
1116+
return 1;
1117+
}
1118+
};
1119+
return new MessageEvent(
1120+
new Message.Builder().role(Message.Role.AGENT).parts(unreadableParts).build());
10701121
}
10711122

10721123
@SuppressWarnings("unchecked") // cast for Mockito

0 commit comments

Comments
 (0)