diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index 975acc31..54380d16 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -584,9 +584,12 @@ async def _get_content( parts: Iterable[types.Part], *, provider: str = "", -) -> Union[OpenAIMessageContent, str]: +) -> OpenAIMessageContent: """Converts a list of parts to litellm content. + Thought parts represent internal model reasoning and are always dropped so + they are not replayed back to the model in subsequent turns. + Args: parts: The parts to convert. provider: The LLM provider name (e.g., "openai", "azure"). @@ -595,11 +598,25 @@ async def _get_content( The litellm content. """ + parts_without_thought = [part for part in parts if not part.thought] + if len(parts_without_thought) == 1: + part = parts_without_thought[0] + if part.text: + return part.text + if ( + part.inline_data + and part.inline_data.data + and part.inline_data.mime_type + and part.inline_data.mime_type.startswith("text/") + ): + return _decode_inline_text_data(part.inline_data.data) + content_objects = [] - for part in parts: + for part in parts_without_thought: + # Skip thought parts to prevent reasoning from being replayed in subsequent + # turns. Thought parts are internal model reasoning and should not be sent + # back to the model. if part.text: - if len(parts) == 1: - return part.text content_objects.append({ "type": "text", "text": part.text, @@ -611,8 +628,6 @@ async def _get_content( ): if part.inline_data.mime_type.startswith("text/"): decoded_text = _decode_inline_text_data(part.inline_data.data) - if len(parts) == 1: - return decoded_text content_objects.append({ "type": "text", "text": decoded_text, diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index ca36966c..bfdedeaa 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -2086,6 +2086,45 @@ def test_split_message_content_prefers_existing_structured_calls(): assert tool_calls == [tool_call] +@pytest.mark.asyncio +async def test_get_content_filters_thought_parts(): + """Test that thought parts are filtered from content. + + Thought parts contain model reasoning that should not be sent back to + the model in subsequent turns. This test verifies that _get_content + skips parts with thought=True. + + See: https://github.com/google/adk-python/issues/3948 + """ + # Create a thought part (reasoning) and a regular text part + thought_part = types.Part(text="Internal reasoning...", thought=True) + regular_part = types.Part.from_text(text="Visible response") + parts = [thought_part, regular_part] + + content = await _get_content(parts) + + # The thought part should be filtered out, leaving only the regular text + assert content == "Visible response" + + +@pytest.mark.asyncio +async def test_get_content_filters_all_thought_parts(): + """Test that all thought parts are filtered when only thoughts present. + + When all parts are thought parts, _get_content should return an empty list. + + See: https://github.com/google/adk-python/issues/3948 + """ + thought_part1 = types.Part(text="First reasoning...", thought=True) + thought_part2 = types.Part(text="Second reasoning...", thought=True) + parts = [thought_part1, thought_part2] + + content = await _get_content(parts) + + # All thought parts should be filtered out + assert content == [] + + @pytest.mark.asyncio async def test_get_content_text(): parts = [types.Part.from_text(text="Test text")]