From 164ae203864bec87fe6c348be769c8a4ef0c4b45 Mon Sep 17 00:00:00 2001 From: Peter Boers Date: Sat, 5 Sep 2026 10:43:49 +0200 Subject: [PATCH] fix(a2a): don't let a caller-authored message hide the agent's reply Skipping the echoed caller message returned "" instead of continuing to the next extraction branch, so a task that echoes the prompt in status.message while carrying its answer in artifacts came back empty: {"status": {"message": {"role": "user", ...}}, <- echo, skipped "artifacts": [{"parts": [{"text": "..."}]}]} <- never reached Before the role filter this returned the echoed prompt (wrong, but visible); after it the reply was dropped entirely. Skip caller-authored messages and fall through, so extraction still reaches the agent's own output. Co-Authored-By: Claude Opus 5 (1M context) --- litellm/llms/a2a/common_utils.py | 16 +++----- tests/unit/llms/a2a/test_common_utils.py | 49 +++++++++++++++++++++++- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/litellm/llms/a2a/common_utils.py b/litellm/llms/a2a/common_utils.py index 47639cd6031..0c83b80fb72 100644 --- a/litellm/llms/a2a/common_utils.py +++ b/litellm/llms/a2a/common_utils.py @@ -126,17 +126,15 @@ def extract_text_from_a2a_response(response_dict: Mapping[str, object], max_dept # 4. Task with status message: {"result": {"kind": "task", "status": {"message": {"parts": [...]}}}} # 5. Streaming artifact-update: {"result": {"kind": "artifact-update", "artifact": {"parts": [...]}}} - # Check if result itself has parts (direct message) - if "parts" in result: - if _is_user_authored(result): - return "" + # Check if result itself has parts (direct message). A caller-authored message is + # skipped rather than returned, so extraction falls through to the agent's own + # output further down (artifacts, typically) instead of yielding nothing. + if "parts" in result and not _is_user_authored(result): return extract_text_from_a2a_message(result, depth=0, max_depth=max_depth) # Check for nested message message: Final = result.get("message") - if message: - if _is_user_authored(message): - return "" + if message and not _is_user_authored(message): return extract_text_from_a2a_message(message, depth=0, max_depth=max_depth) # Check for streaming artifact-update (singular artifact) @@ -148,9 +146,7 @@ def extract_text_from_a2a_response(response_dict: Mapping[str, object], max_dept status: Final = result.get("status", {}) if isinstance(status, dict): status_message: Final = status.get("message") - if status_message: - if _is_user_authored(status_message): - return "" + if status_message and not _is_user_authored(status_message): return extract_text_from_a2a_message(status_message, depth=0, max_depth=max_depth) # Handle task result with artifacts (plural, array) diff --git a/tests/unit/llms/a2a/test_common_utils.py b/tests/unit/llms/a2a/test_common_utils.py index 6047edb3f4f..2fbb2f4abb4 100644 --- a/tests/unit/llms/a2a/test_common_utils.py +++ b/tests/unit/llms/a2a/test_common_utils.py @@ -5,7 +5,7 @@ from types import MappingProxyType import pytest -from litellm.llms.a2a.common_utils import resolve_a2a_hop_auth_header +from litellm.llms.a2a.common_utils import extract_text_from_a2a_response, resolve_a2a_hop_auth_header class _RecordingEntraResolver: @@ -50,3 +50,50 @@ async def test_agent_without_entra_credentials_gets_no_bearer(): assert header is None assert resolver.calls == [] + + +def _task(*, status_role: str | None = None, status_text: str = "", artifact_text: str | None = None) -> dict: + result: dict = {"kind": "task"} + if status_role is not None: + result["status"] = { + "state": "completed", + "message": {"role": status_role, "parts": [{"kind": "text", "text": status_text}]}, + } + if artifact_text is not None: + result["artifacts"] = [{"parts": [{"kind": "text", "text": artifact_text}]}] + return {"jsonrpc": "2.0", "id": "1", "result": result} + + +@pytest.mark.parametrize( + "response, expected", + [ + # Regression: a task echoing the caller in status.message while carrying the real + # answer in artifacts must not come back empty. Skipping a caller-authored message + # has to fall through to the agent's own output, not short-circuit extraction. + pytest.param( + _task(status_role="user", status_text="check active alarms", artifact_text="THE ANSWER"), + "THE ANSWER", + id="user_echo_falls_through_to_artifacts", + ), + pytest.param( + _task(status_role="agent", status_text="agent status", artifact_text="THE ANSWER"), + "agent status", + id="agent_status_message_preferred", + ), + pytest.param(_task(artifact_text="THE ANSWER"), "THE ANSWER", id="artifacts_only"), + pytest.param(_task(status_role="user", status_text="echo"), "", id="user_echo_alone_is_empty"), + pytest.param( + {"result": {"kind": "message", "role": "user", "parts": [{"kind": "text", "text": "echo"}]}}, + "", + id="direct_user_message_is_empty", + ), + pytest.param( + {"result": {"kind": "message", "role": "agent", "parts": [{"kind": "text", "text": "hi"}]}}, + "hi", + id="direct_agent_message", + ), + ], +) +def test_extract_text_skips_caller_authored_messages(response, expected): + """Caller-authored parts are skipped, never returned, and never hide the agent's reply.""" + assert extract_text_from_a2a_response(response) == expected