Skip to content

Commit 06b9b22

Browse files
fix: mark failed A2A tasks as error events
1 parent 2e878ed commit 06b9b22

7 files changed

Lines changed: 384 additions & 39 deletions

File tree

src/google/adk/a2a/_compat.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ def _proto_to_dict(msg: Any) -> dict[str, Any]:
139139
TS_AUTH_REQUIRED = TaskState.Value("TASK_STATE_AUTH_REQUIRED")
140140
TS_CANCELED = TaskState.Value("TASK_STATE_CANCELED")
141141

142+
A2A_TASK_FAILED_ERROR_CODE = "A2A_TASK_FAILED"
143+
A2A_TASK_FAILED_ERROR_MESSAGE = "Remote agent task failed"
144+
142145
TP_JSONRPC = TransportProtocol.JSONRPC
143146
TP_HTTP_JSON = TransportProtocol.HTTP_JSON
144147
TP_GRPC = TransportProtocol.GRPC
@@ -154,6 +157,9 @@ def _proto_to_dict(msg: Any) -> dict[str, Any]:
154157
TS_AUTH_REQUIRED = TaskState.auth_required
155158
TS_CANCELED = TaskState.canceled
156159

160+
A2A_TASK_FAILED_ERROR_CODE = "A2A_TASK_FAILED"
161+
A2A_TASK_FAILED_ERROR_MESSAGE = "Remote agent task failed"
162+
157163
TP_JSONRPC = getattr(TransportProtocol, "jsonrpc")
158164
TP_HTTP_JSON = getattr(TransportProtocol, "http_json")
159165
TP_GRPC = getattr(TransportProtocol, "grpc")
@@ -1187,6 +1193,11 @@ def role_to_str(role: Any) -> str:
11871193
return "user" if role == ROLE_USER else "model"
11881194

11891195

1196+
def is_failed_status(status: Any) -> bool:
1197+
"""Returns whether an A2A task status is in the failed state."""
1198+
return status is not None and getattr(status, "state", None) == TS_FAILED
1199+
1200+
11901201
def normalize_message(msg: Any) -> Any:
11911202
"""Collapses an empty 1.x proto ``Message`` to ``None``.
11921203

src/google/adk/a2a/converters/event_converter.py

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,23 @@
5252
logger = logging.getLogger("google_adk." + __name__)
5353

5454

55+
def _extract_text_from_event(event: Event) -> str:
56+
"""Returns the text content of an ADK event, or an empty string."""
57+
if event.content and event.content.parts:
58+
return "".join(part.text or "" for part in event.content.parts if part.text)
59+
return ""
60+
61+
62+
def _mark_a2a_task_failed(event: Event) -> Event:
63+
"""Marks an event produced from a failed A2A task."""
64+
event.error_code = _compat.A2A_TASK_FAILED_ERROR_CODE
65+
if not event.error_message:
66+
event.error_message = (
67+
_extract_text_from_event(event) or _compat.A2A_TASK_FAILED_ERROR_MESSAGE
68+
)
69+
return event
70+
71+
5572
AdkEventToA2AEventsConverter = Callable[
5673
[
5774
Event,
@@ -250,27 +267,30 @@ def convert_a2a_task_to_event(
250267
if agent_messages:
251268
message = agent_messages[-1]
252269

253-
# Convert message if available
270+
# Convert message if available; otherwise create a minimal event.
254271
if message:
255272
try:
256273
event: Event = convert_a2a_message_to_event(
257274
message, author, invocation_context, part_converter=part_converter
258275
)
259-
return event
260276
except Exception as e:
261277
logger.error("Failed to convert A2A task message to event: %s", e)
262278
raise RuntimeError(f"Failed to convert task message: {e}") from e
279+
else:
280+
event = Event(
281+
invocation_id=(
282+
invocation_context.invocation_id
283+
if invocation_context
284+
else platform_uuid.new_uuid()
285+
),
286+
author=author or "a2a agent",
287+
branch=invocation_context.branch if invocation_context else None,
288+
)
263289

264-
# Create minimal event if no message is available
265-
return Event(
266-
invocation_id=(
267-
invocation_context.invocation_id
268-
if invocation_context
269-
else platform_uuid.new_uuid()
270-
),
271-
author=author or "a2a agent",
272-
branch=invocation_context.branch if invocation_context else None,
273-
)
290+
if _compat.is_failed_status(a2a_task.status):
291+
event = _mark_a2a_task_failed(event)
292+
293+
return event
274294

275295
except Exception as e:
276296
logger.error("Failed to convert A2A task to event: %s", e)

src/google/adk/a2a/converters/to_adk_event.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,12 +195,15 @@ def _create_event(
195195
custom_metadata: Any = None,
196196
usage_metadata: Any = None,
197197
error_code: Any = None,
198+
error_message: Any = None,
198199
citation_metadata: Any = None,
199200
) -> Optional[Event]:
200201
"""Creates an ADK event from parts and metadata."""
201202
event_actions = actions or EventActions()
202-
if not output_parts and not event_actions.model_dump(
203-
exclude_none=True, exclude_defaults=True
203+
if (
204+
not output_parts
205+
and not error_code
206+
and not event_actions.model_dump(exclude_none=True, exclude_defaults=True)
204207
):
205208
return None
206209

@@ -229,12 +232,30 @@ def _create_event(
229232
custom_metadata=custom_metadata,
230233
usage_metadata=usage_metadata,
231234
error_code=error_code,
235+
error_message=error_message,
232236
citation_metadata=citation_metadata,
233237
)
234238

235239
return event
236240

237241

242+
def _extract_text_from_event(event: Event) -> str:
243+
"""Returns the text content of an ADK event, or an empty string."""
244+
if event.content and event.content.parts:
245+
return "".join(part.text or "" for part in event.content.parts if part.text)
246+
return ""
247+
248+
249+
def _mark_a2a_task_failed(event: Event) -> Event:
250+
"""Marks an event produced from a failed A2A task."""
251+
event.error_code = _compat.A2A_TASK_FAILED_ERROR_CODE
252+
if not event.error_message:
253+
event.error_message = (
254+
_extract_text_from_event(event) or _compat.A2A_TASK_FAILED_ERROR_MESSAGE
255+
)
256+
return event
257+
258+
238259
def _a2a_role_to_content_role(role: Optional[Role]) -> str:
239260
"""Maps an A2A Role to the corresponding GenAI content role."""
240261
return _compat.role_to_str(role)
@@ -513,6 +534,7 @@ def convert_a2a_task_to_event(
513534
if status_message and (
514535
a2a_task.status.state == _compat.TS_INPUT_REQUIRED
515536
or a2a_task.status.state == _compat.TS_AUTH_REQUIRED
537+
or _compat.is_failed_status(a2a_task.status)
516538
):
517539
event_actions = _merge_event_actions(
518540
event_actions,
@@ -534,14 +556,24 @@ def convert_a2a_task_to_event(
534556
)
535557
)
536558

537-
return _create_event(
559+
event = _create_event(
538560
output_parts,
539561
invocation_context,
540562
author,
541563
event_actions,
542564
long_running_function_ids,
543565
**metadata_fields,
544566
)
567+
if _compat.is_failed_status(a2a_task.status):
568+
if event is None:
569+
event = _create_event(
570+
[],
571+
invocation_context,
572+
author,
573+
error_code=_compat.A2A_TASK_FAILED_ERROR_CODE,
574+
)
575+
event = _mark_a2a_task_failed(event)
576+
return event
545577

546578
except Exception as e:
547579
logger.error("Failed to convert A2A task to event: %s", e)
@@ -581,14 +613,15 @@ def convert_a2a_message_to_event(
581613
)
582614
content_role = _a2a_role_to_content_role(getattr(a2a_message, "role", None))
583615
metadata_fields = _extract_all_metadata_fields(a2a_message.metadata)
584-
return _create_event(
616+
event = _create_event(
585617
output_parts,
586618
invocation_context,
587619
author,
588620
_extract_event_actions(a2a_message.metadata),
589621
content_role=content_role,
590622
**metadata_fields,
591623
)
624+
return event
592625

593626
except Exception as e:
594627
logger.error("Failed to convert A2A message to event: %s", e)
@@ -639,14 +672,24 @@ def convert_a2a_status_update_to_event(
639672
)
640673
)
641674

642-
return _create_event(
675+
event = _create_event(
643676
output_parts,
644677
invocation_context,
645678
author,
646679
event_actions,
647680
long_running_function_ids,
648681
**metadata_fields,
649682
)
683+
if _compat.is_failed_status(a2a_status_update.status):
684+
if event is None:
685+
event = _create_event(
686+
[],
687+
invocation_context,
688+
author,
689+
error_code=_compat.A2A_TASK_FAILED_ERROR_CODE,
690+
)
691+
event = _mark_a2a_task_failed(event)
692+
return event
650693
except Exception as e:
651694
logger.error("Failed to convert A2A status update to event: %s", e)
652695
raise RuntimeError(f"Failed to convert status update: {e}") from e

src/google/adk/agents/remote_a2a_agent.py

Lines changed: 96 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,30 @@
122122
})
123123

124124

125+
def _mark_a2a_task_failed_event(
126+
event: Optional[Event],
127+
*,
128+
author: str,
129+
invocation_context: InvocationContext,
130+
) -> Event:
131+
"""Marks an event produced from a failed A2A task, creating one if needed."""
132+
if event is None:
133+
event = Event(
134+
author=author,
135+
invocation_id=invocation_context.invocation_id,
136+
branch=invocation_context.branch,
137+
)
138+
event.error_code = _compat.A2A_TASK_FAILED_ERROR_CODE
139+
if not event.error_message:
140+
text = "".join(
141+
part.text or ""
142+
for part in (event.content.parts if event.content else [])
143+
if part.text
144+
)
145+
event.error_message = text or _compat.A2A_TASK_FAILED_ERROR_MESSAGE
146+
return event
147+
148+
125149
def _payload_is_auth_config(payload: Any) -> bool:
126150
"""Whether a payload looks like a serialized AuthConfig (fail closed)."""
127151
candidate = payload
@@ -774,7 +798,16 @@ async def _handle_a2a_response(
774798
task, self.name, ctx, self._a2a_part_converter
775799
)
776800
if not event:
777-
return None
801+
if _compat.is_failed_status(getattr(task, "status", None)):
802+
event = _mark_a2a_task_failed_event(
803+
None, author=self.name, invocation_context=ctx
804+
)
805+
else:
806+
return None
807+
elif _compat.is_failed_status(getattr(task, "status", None)):
808+
event = _mark_a2a_task_failed_event(
809+
event, author=self.name, invocation_context=ctx
810+
)
778811
# for streaming task, we update the event with the task status.
779812
# We update the event as Thought updates.
780813
if (
@@ -791,29 +824,54 @@ async def _handle_a2a_response(
791824
for part in event.content.parts or []:
792825
part.thought = True
793826
_add_mock_function_call(event, task.status.state)
794-
elif isinstance(update, A2ATaskStatusUpdateEvent) and (
795-
_status_message := (
796-
_compat.normalize_message(update.status.message)
797-
if update.status
798-
else None
799-
)
800-
):
801-
# This is a streaming task status update with a message.
827+
elif isinstance(update, A2ATaskStatusUpdateEvent):
802828
# ``normalize_message`` collapses the always-present empty proto
803-
# ``Message`` (1.x) to ``None`` so this branch only fires when a real
804-
# message is attached, matching 0.3.x where the field is ``None``.
805-
event = convert_a2a_message_to_event(
806-
_status_message, self.name, ctx, self._a2a_part_converter
829+
# ``Message`` (1.x) to ``None`` so status updates without a real
830+
# message are handled explicitly below, matching 0.3.x.
831+
_status_message = (
832+
_compat.normalize_message(update.status.message)
833+
if update.status
834+
else None
807835
)
808-
if not event:
836+
failed_state = (
837+
_compat.TS_FAILED
838+
if _compat.is_failed_status(getattr(update, "status", None))
839+
or _compat.is_failed_status(getattr(task, "status", None))
840+
else None
841+
)
842+
if failed_state is not None:
843+
event = (
844+
convert_a2a_message_to_event(
845+
_status_message,
846+
self.name,
847+
ctx,
848+
self._a2a_part_converter,
849+
)
850+
if _status_message
851+
else None
852+
)
853+
event = _mark_a2a_task_failed_event(
854+
event, author=self.name, invocation_context=ctx
855+
)
856+
_add_mock_function_call(event, failed_state)
857+
elif _status_message:
858+
# This is a streaming task status update with a message.
859+
event = convert_a2a_message_to_event(
860+
_status_message, self.name, ctx, self._a2a_part_converter
861+
)
862+
if not event:
863+
return None
864+
if event.content is not None and update.status.state in (
865+
_compat.TS_SUBMITTED,
866+
_compat.TS_WORKING,
867+
):
868+
for part in event.content.parts or []:
869+
part.thought = True
870+
_add_mock_function_call(event, update.status.state)
871+
else:
872+
# This is a streaming status update without a message (e.g. status
873+
# change). We don't emit an event for non-failed updates.
809874
return None
810-
if event.content is not None and update.status.state in (
811-
_compat.TS_SUBMITTED,
812-
_compat.TS_WORKING,
813-
):
814-
for part in event.content.parts or []:
815-
part.thought = True
816-
_add_mock_function_call(event, update.status.state)
817875
elif isinstance(update, A2ATaskArtifactUpdateEvent):
818876
# This is a streaming task artifact update.
819877
# Convert only the parts carried by this update. Converting the
@@ -904,11 +962,28 @@ async def _handle_a2a_response_v2(
904962
event = self._config.a2a_task_converter(
905963
task, self.name, ctx, self._config.a2a_part_converter
906964
)
965+
if not event:
966+
if _compat.is_failed_status(getattr(task, "status", None)):
967+
event = _mark_a2a_task_failed_event(
968+
None, author=self.name, invocation_context=ctx
969+
)
970+
else:
971+
return None
972+
elif _compat.is_failed_status(getattr(task, "status", None)):
973+
event = _mark_a2a_task_failed_event(
974+
event, author=self.name, invocation_context=ctx
975+
)
907976
elif isinstance(update, A2ATaskStatusUpdateEvent):
908977
# This is a streaming task status update.
909978
event = self._config.a2a_status_update_converter(
910979
update, self.name, ctx, self._config.a2a_part_converter
911980
)
981+
if _compat.is_failed_status(getattr(update, "status", None)) or (
982+
_compat.is_failed_status(getattr(task, "status", None))
983+
):
984+
event = _mark_a2a_task_failed_event(
985+
event, author=self.name, invocation_context=ctx
986+
)
912987
elif isinstance(update, A2ATaskArtifactUpdateEvent):
913988
# This is a streaming task artifact update.
914989
event = self._config.a2a_artifact_update_converter(

0 commit comments

Comments
 (0)