diff --git a/src/google/adk/models/anthropic_llm.py b/src/google/adk/models/anthropic_llm.py index 00ccc831e2..3f9db6ebc9 100644 --- a/src/google/adk/models/anthropic_llm.py +++ b/src/google/adk/models/anthropic_llm.py @@ -336,6 +336,16 @@ def _is_pdf_part(part: types.Part) -> bool: ) +def _is_empty_text_part(part: types.Part) -> bool: + """Returns True for parts that carry nothing but an empty text string. + + ADK itself can produce such parts (e.g. code execution with no output writes + `Part(text='')` into the content), and Anthropic rejects empty text blocks. + """ + payload = part.model_dump(exclude_none=True) + return payload.get("text") == "" and set(payload) <= {"text", "thought"} + + def _normalize_image_media_type(mime_type: str) -> _ImageMediaType: normalized = mime_type.split(";", 1)[0].strip().lower() if normalized not in _ANTHROPIC_IMAGE_MEDIA_TYPES: @@ -575,6 +585,12 @@ def _content_to_message_param( logger.warning("PDF data is not supported in Claude for assistant turns.") continue + # Anthropic rejects empty text blocks; skip them rather than failing the + # whole request with NotImplementedError. + if _is_empty_text_part(part): + logger.debug("Skipping empty text part for Claude request.") + continue + message_block.append(_part_to_message_block(part, sanitizer)) return { diff --git a/tests/unittests/models/test_anthropic_llm.py b/tests/unittests/models/test_anthropic_llm.py index d363c15112..345b349c91 100644 --- a/tests/unittests/models/test_anthropic_llm.py +++ b/tests/unittests/models/test_anthropic_llm.py @@ -1080,6 +1080,38 @@ def test_content_to_message_param( # --- Tests for Bug #2: json.dumps for dict/list function results --- +def test_content_to_message_param_skips_empty_text_part(): + """An empty text part must be skipped instead of raising NotImplementedError. + + ADK can emit `Part(text='')` itself, e.g. when code execution produces no + output, and Anthropic rejects empty text blocks. + """ + content = types.Content( + role="user", + parts=[ + types.Part(text="run it"), + types.Part(text=""), + ], + ) + + result = content_to_message_param(content) + + assert result["role"] == "user" + assert result["content"] == [{"type": "text", "text": "run it"}] + + +def test_content_to_message_param_keeps_non_text_payload_with_empty_text(): + """A part that has other payload alongside empty text is not dropped.""" + part = types.Part(text="") + part.function_call = types.FunctionCall(id="call_1", name="tool", args={}) + content = types.Content(role="model", parts=[part]) + + result = content_to_message_param(content) + + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "tool_use" + + def test_part_to_message_block_dict_result_serialized_as_json(): """Dict results should be serialized with json.dumps, not str().""" response_part = types.Part.from_function_response(