Summary
to_adk_event.py has four inbound converters. All four call
_convert_a2a_parts_to_adk_parts, which correctly recovers long-running
function call ids from the adk_is_long_running part marker. Two of them then
discard that result and never pass it to _create_event, so the returned
Event has long_running_tool_ids=None.
| converter |
_create_event call |
passes ids |
convert_a2a_task_to_event |
to_adk_event.py:537 |
yes |
convert_a2a_status_update_to_event |
to_adk_event.py:642 |
yes |
convert_a2a_message_to_event |
to_adk_event.py:584 |
no |
convert_a2a_artifact_update_to_event |
to_adk_event.py:684 |
no |
Both failing sites share the same shape, discarding the second return value:
output_parts, _ = _convert_a2a_parts_to_adk_parts(
a2a_message.parts, part_converter
)
and then omit the positional long_running_function_ids argument that the two
working converters pass.
This is not a guess about intent. The docstring on
convert_a2a_message_to_event states it returns "an ADK Event object with
converted content and long-running function metadata", and the sibling
converters in the same file do exactly that.
Verification
Repro below builds the A2A message with a real ADK converter, so the wire
marker is what a served ADK agent actually emits. No credentials, no network.
adk-python HEAD : d637d1b449aa4382045f71ee84d3f674403d41d5
wire part metadata : [{'adk_type': 'function_call', 'adk_is_long_running': True}]
helper recovers : {'lrt-call-0001'} <- extraction works
expected : {'lrt-call-0001'}
converter _create_event long_running_tool_ids
----------------------------------------------------------------------------------
convert_a2a_task_to_event to_adk_event:537 ['lrt-call-0001'] PASS
convert_a2a_status_update_to_event to_adk_event:642 ['lrt-call-0001'] PASS
convert_a2a_message_to_event to_adk_event:584 None FAIL
convert_a2a_artifact_update_to_event to_adk_event:684 None FAIL
repro script
"""Repro: two of the four inbound A2A converters discard long_running_tool_ids.
`_convert_a2a_parts_to_adk_parts` correctly recovers the ids from the
`adk_is_long_running` part marker in every case. Two of its four callers then
throw the result away and never pass it to `_create_event`, so the Event comes
back with `long_running_tool_ids=None`.
The wire input is produced by a real ADK converter, not hand-built, so the
marker is exactly what a served ADK agent emits.
No credentials, no network. Run: .venv/bin/python repro/repro_lrti.py
"""
from __future__ import annotations
import subprocess
import sys
import warnings
from pathlib import Path
from unittest import mock
warnings.filterwarnings("ignore")
from google.adk.a2a import _compat
from google.adk.a2a.converters import event_converter as legacy
from google.adk.a2a.converters import to_adk_event as inbound
from google.adk.events.event import Event
from google.genai import types as genai_types
CALL_ID = "lrt-call-0001"
def sent_event() -> Event:
"""What a served ADK agent emits when it starts a long-running tool."""
return Event(
invocation_id="inv-1",
author="remote_agent",
long_running_tool_ids={CALL_ID},
content=genai_types.Content(
role="model",
parts=[
genai_types.Part(
function_call=genai_types.FunctionCall(
id=CALL_ID,
name="wait_for_human_approval",
args={"ticket": "T-42"},
)
)
],
),
)
def wire_message():
"""A real A2A Message carrying the long-running marker."""
return legacy.convert_event_to_a2a_message(sent_event(), mock.MagicMock())
def main() -> int:
repo = Path(__file__).resolve().parent.parent / "adk-python"
sha = subprocess.run(["git", "-C", str(repo), "rev-parse", "HEAD"],
capture_output=True, text=True, check=False).stdout.strip()
print(f"adk-python HEAD : {sha}")
print(f"python : {sys.version.split()[0]}\n")
message = wire_message()
markers = [dict(_compat.part_metadata(p) or {}) for p in message.parts]
print(f"wire part metadata : {markers}")
# Control: the shared helper recovers the ids from that marker correctly.
_, recovered = inbound._convert_a2a_parts_to_adk_parts(
message.parts, inbound.convert_a2a_part_to_genai_part
)
print(f"helper recovers : {recovered or None} <- extraction works")
print(f"expected : {{'{CALL_ID}'}}\n")
task = _compat.make_task(
id="task", context_id="ctx",
status=_compat.make_task_status(_compat.TS_INPUT_REQUIRED, message=message),
)
status_update = _compat.make_task_status_update_event(
task_id="task", context_id="ctx",
status=_compat.make_task_status(_compat.TS_INPUT_REQUIRED, message=message),
final=True,
)
artifact_update = _compat.TaskArtifactUpdateEvent(
task_id="task", context_id="ctx", last_chunk=True,
artifact=_compat.make_artifact(
artifact_id="art-1", parts=list(message.parts)),
)
cases = [
("convert_a2a_task_to_event", 537,
lambda: inbound.convert_a2a_task_to_event(task)),
("convert_a2a_status_update_to_event", 642,
lambda: inbound.convert_a2a_status_update_to_event(status_update)),
("convert_a2a_message_to_event", 584,
lambda: inbound.convert_a2a_message_to_event(message)),
("convert_a2a_artifact_update_to_event", 684,
lambda: inbound.convert_a2a_artifact_update_to_event(artifact_update)),
]
print(f"{'converter':38} {'_create_event':>14} {'long_running_tool_ids':>24}")
print("-" * 82)
failures = []
for name, line, call in cases:
try:
event = call()
got = event.long_running_tool_ids if event else None
except Exception as exc:
got = f"ERROR {type(exc).__name__}"
ok = got == {CALL_ID}
if not ok:
failures.append(name)
shown = sorted(got) if isinstance(got, set) else got
print(f"{name:38} {'to_adk_event:' + str(line):>14} {str(shown):>24}"
f" {'PASS' if ok else 'FAIL'}")
print("\n================ VERDICT ================")
if failures:
print("CONFIRMED. The marker is on the wire and the shared helper recovers")
print("it, but these callers drop it before building the Event:")
for name in failures:
print(f" - {name}")
print("\nBoth use `output_parts, _ = _convert_a2a_parts_to_adk_parts(...)`")
print("and omit the long_running_function_ids argument to _create_event.")
print("The two passing converters pass it positionally.")
return 1
print("No gap: every converter preserved the field.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
The helper recovers line is the control. Extraction works in every case, so
the loss is at the call site, not in the marker or the parsing.
Why this matters
Both failing converters are the defaults on A2aRemoteAgentConfig
(a2a/agent/config.py:112 and :125), and every RemoteA2aAgent builds that
config when none is passed (remote_a2a_agent.py:713).
They are reached through _handle_a2a_response_v2, which
remote_a2a_agent.py:1636 selects when the response carries the new
integration extension:
if metadata and _compat.metadata_get(
metadata, _NEW_A2A_ADK_INTEGRATION_EXTENSION
):
event = await self._handle_a2a_response_v2(a2a_response, ctx)
else:
event = await self._handle_a2a_response(a2a_response, ctx)
So this is not every A2A call. It is the path taken once both ends speak the
new extension, and the legacy path it replaces
(event_converter.convert_a2a_message_to_event) populates the field correctly.
Upgrading both ends silently moves a caller from working behaviour to a
long-running call that arrives looking complete. Nothing raises and nothing
logs.
I confirmed this from the call graph and the converter defaults, not from a
live two-agent run. Flagging that rather than claiming an end-to-end user
report.
Test coverage matches the gap: the only long_running_tool_ids assertions in
tests/unittests/a2a/converters/test_to_adk.py (lines 616 and 655) both cover
convert_a2a_task_to_event. Neither failing converter is asserted on, and
test_event_round_trip.py::test_round_trip_function_call_event does not check
the field.
Suggested fix
Keep the recovered ids and pass them, matching the two working converters:
output_parts, long_running_function_ids = _convert_a2a_parts_to_adk_parts(
a2a_message.parts, part_converter
)
...
return _create_event(
output_parts,
invocation_context,
author,
_extract_event_actions(a2a_message.metadata),
long_running_function_ids,
content_role=content_role,
**metadata_fields,
)
Same change at to_adk_event.py:684 for the artifact update converter. Happy
to open a PR with both call sites and tests covering all four converters.
Context
Filing this separately at @GWeale's request on #5185: "one gap is left, the
message converter still drops long_running_tool_ids, so please file that
separately." The scope turned out to be two converters rather than one.
Summary
to_adk_event.pyhas four inbound converters. All four call_convert_a2a_parts_to_adk_parts, which correctly recovers long-runningfunction call ids from the
adk_is_long_runningpart marker. Two of them thendiscard that result and never pass it to
_create_event, so the returnedEventhaslong_running_tool_ids=None._create_eventcallconvert_a2a_task_to_eventto_adk_event.py:537convert_a2a_status_update_to_eventto_adk_event.py:642convert_a2a_message_to_eventto_adk_event.py:584convert_a2a_artifact_update_to_eventto_adk_event.py:684Both failing sites share the same shape, discarding the second return value:
and then omit the positional
long_running_function_idsargument that the twoworking converters pass.
This is not a guess about intent. The docstring on
convert_a2a_message_to_eventstates it returns "an ADK Event object withconverted content and long-running function metadata", and the sibling
converters in the same file do exactly that.
Verification
Repro below builds the A2A message with a real ADK converter, so the wire
marker is what a served ADK agent actually emits. No credentials, no network.
repro script
The
helper recoversline is the control. Extraction works in every case, sothe loss is at the call site, not in the marker or the parsing.
Why this matters
Both failing converters are the defaults on
A2aRemoteAgentConfig(
a2a/agent/config.py:112and:125), and everyRemoteA2aAgentbuilds thatconfig when none is passed (
remote_a2a_agent.py:713).They are reached through
_handle_a2a_response_v2, whichremote_a2a_agent.py:1636selects when the response carries the newintegration extension:
So this is not every A2A call. It is the path taken once both ends speak the
new extension, and the legacy path it replaces
(
event_converter.convert_a2a_message_to_event) populates the field correctly.Upgrading both ends silently moves a caller from working behaviour to a
long-running call that arrives looking complete. Nothing raises and nothing
logs.
I confirmed this from the call graph and the converter defaults, not from a
live two-agent run. Flagging that rather than claiming an end-to-end user
report.
Test coverage matches the gap: the only
long_running_tool_idsassertions intests/unittests/a2a/converters/test_to_adk.py(lines 616 and 655) both coverconvert_a2a_task_to_event. Neither failing converter is asserted on, andtest_event_round_trip.py::test_round_trip_function_call_eventdoes not checkthe field.
Suggested fix
Keep the recovered ids and pass them, matching the two working converters:
Same change at
to_adk_event.py:684for the artifact update converter. Happyto open a PR with both call sites and tests covering all four converters.
Context
Filing this separately at @GWeale's request on #5185: "one gap is left, the
message converter still drops long_running_tool_ids, so please file that
separately." The scope turned out to be two converters rather than one.