From a6aa5e954306a0006f043a1d28b7080b566f5271 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Mon, 3 Aug 2026 09:30:41 -0700 Subject: [PATCH] Python: surface Gemini thought summaries as reasoning content Gemini thought-summary parts (part.thought=True) were dropped in _parse_parts, so reasoning never reached ChatResponse.contents. Emit them as text_reasoning content instead, matching OpenAIResponsesClient. Round-trip is safe: _convert_message_contents never re-emits reasoning text as a Part. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b8aa906e-1408-40c1-9a45-6deb40dc36f8 --- .../agent_framework_gemini/_chat_client.py | 11 +++++---- .../gemini/tests/test_gemini_client.py | 24 ++++++++++++++++--- 2 files changed, 28 insertions(+), 7 deletions(-) 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()