diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index cd053711ca..26b5947442 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -78,8 +78,8 @@ class ThinkingConfig(TypedDict, total=False): Attributes: include_thoughts: Whether to include thought summaries in the response. Thought summaries are condensed representations of the model's internal reasoning and appear as response - parts where ``part.thought`` is ``True``. Note: the framework currently excludes - thought parts from ``ChatResponse.contents`` and does not surface them as output. + parts where ``part.thought`` is ``True``. When set, the framework surfaces these parts + as ``text_reasoning`` content in ``ChatResponse.contents``. thinking_budget: Token budget for Gemini 2.5 models. Set to ``0`` to disable thinking or ``-1`` to enable a dynamic budget. thinking_level: Thinking level for Gemini 2.5 models and later. One of @@ -1120,17 +1120,20 @@ def _process_chunk(self, chunk: types.GenerateContentResponse) -> ChatResponseUp ) def _parse_parts(self, parts: Sequence[types.Part]) -> list[Content]: - """Convert Gemini response parts to framework Content objects, skipping thought/reasoning parts. + """Convert Gemini response parts to framework Content objects. Args: parts: Sequence of ``types.Part`` objects from a Gemini response candidate. Returns: - A list of framework ``Content`` objects (text, function_call, or function_result). + A list of framework ``Content`` objects (text_reasoning, text, function_call, or + function_result). """ contents: list[Content] = [] for part in parts: if part.thought: + if part.text: + contents.append(Content.from_text_reasoning(text=part.text, raw_representation=part)) continue if part.text is not None: contents.append(Content.from_text(text=part.text, raw_representation=part)) diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index 7dbda00ca9..6619e8cfb0 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -714,8 +714,8 @@ async def test_non_function_result_content_in_tool_message_is_skipped() -> None: # thinking parts -async def test_thinking_parts_are_silently_skipped() -> None: - """Excludes thought-summary parts from ChatResponse.contents, returning only the final answer.""" +async def test_thinking_parts_are_surfaced_as_reasoning() -> None: + """Surfaces thought-summary parts as text_reasoning content alongside the final answer.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock( return_value=_make_response([ @@ -728,10 +728,28 @@ async def test_thinking_parts_are_silently_skipped() -> None: messages=[Message(role="user", contents=[Content.from_text("What is the answer?")])] ) - assert len(response.messages[0].contents) == 1 + contents = response.messages[0].contents + assert len(contents) == 2 + assert contents[0].type == "text_reasoning" + assert contents[0].text == "I should think first..." + assert contents[1].type == "text" assert response.messages[0].text == "The answer is 42." +async def test_empty_thinking_part_produces_no_reasoning_content() -> None: + """A thought part with no text yields no content rather than empty reasoning.""" + client, _ = _make_gemini_client() + + contents = client._parse_parts([ + _make_part(text=None, thought=True), + _make_part(text="The answer is 42."), + ]) + + assert len(contents) == 1 + assert contents[0].type == "text" + assert contents[0].text == "The answer is 42." + + def test_function_call_part_preserves_thought_signature_from_raw_part() -> None: """Reuses the original Gemini Part so tool loops retain thought_signature metadata.""" client, _ = _make_gemini_client()