From 7bd4634e64c64358fc2827879727659249727453 Mon Sep 17 00:00:00 2001 From: Houyun Sun <18763177732@139.com> Date: Wed, 11 Nov 2026 20:24:29 +0800 Subject: [PATCH] fix(telemetry): export thought parts as reasoning, not text `_to_part` in the experimental semconv builder only looked at `part.text`, so `Part(text=..., thought=True)` ended up as a `TextPart` on `gen_ai.output.messages`. The `thought` flag is all a consumer has to tell the model's reasoning from its answer, and once the part is on the wire as `text` that information is gone -- a trace backend renders the chain of thought as part of the reply. The v1.39.0 semconv the module already points at has a separate `ReasoningPart` (`type: "reasoning"`, same `content` field), so map thought parts onto it. Input messages go through the same `_to_part`, which keeps a replayed thought in the history typed correctly as well. Verified with a scripted `BaseLlm` (no provider call): before the change the exported part types were `['text', 'text']`, after they are `['reasoning', 'text']`. `tests/unittests/telemetry` passes apart from two cases that need a live GCP/Discovery Engine setup and fail the same way without this change. --- .../adk/telemetry/_experimental_semconv.py | 12 +++- .../telemetry/test_experimental_semconv.py | 55 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/google/adk/telemetry/_experimental_semconv.py b/src/google/adk/telemetry/_experimental_semconv.py index 1c97eec0078..a989ac7f82f 100644 --- a/src/google/adk/telemetry/_experimental_semconv.py +++ b/src/google/adk/telemetry/_experimental_semconv.py @@ -94,6 +94,11 @@ class Text(TypedDict): type: Literal['text'] +class Reasoning(TypedDict): + content: str + type: Literal['reasoning'] + + class Blob(TypedDict): mime_type: str data: bytes @@ -119,7 +124,7 @@ class ToolCallResponse(TypedDict): type: Literal['tool_call_response'] -Part = Text | Blob | FileData | ToolCall | ToolCallResponse +Part = Text | Reasoning | Blob | FileData | ToolCall | ToolCallResponse class InputMessage(TypedDict): @@ -277,6 +282,11 @@ def tool_call_id_fallback(name: str | None) -> str: return None if (text := part.text) is not None: + # A thought part is the model's reasoning, not its answer; the semconv + # keeps the two apart (ReasoningPart vs TextPart) and a consumer that + # only sees `text` cannot tell them apart again. + if part.thought: + return Reasoning(content=text, type='reasoning') return Text(content=text, type='text') if inline_data := part.inline_data: diff --git a/tests/unittests/telemetry/test_experimental_semconv.py b/tests/unittests/telemetry/test_experimental_semconv.py index 3083a8a51d9..391e8a9bc53 100644 --- a/tests/unittests/telemetry/test_experimental_semconv.py +++ b/tests/unittests/telemetry/test_experimental_semconv.py @@ -307,6 +307,61 @@ def test_response_attributes_split_between_details_and_common(): } +def test_response_attributes_report_thought_parts_as_reasoning(): + """A thought part is exported as `reasoning`, not as part of the answer. + + Downstream, `text` and `reasoning` are the only thing telling reasoning + apart from the answer, so a thought part losing its flag on the wire + leaves a consumer with no way to tell them apart again. + """ + llm_response = LlmResponse( + content=types.Content( + role='model', + parts=[ + types.Part(text='weighing the options', thought=True), + types.Part(text='the answer'), + types.Part(text='and a trailing note', thought=True), + ], + ), + finish_reason=types.FinishReason.STOP, + ) + + details, _ = _response_attributes(llm_response) + + assert details[GEN_AI_OUTPUT_MESSAGES] == [{ + 'role': 'assistant', + 'parts': [ + {'content': 'weighing the options', 'type': 'reasoning'}, + {'content': 'the answer', 'type': 'text'}, + {'content': 'and a trailing note', 'type': 'reasoning'}, + ], + 'finish_reason': 'stop', + }] + + +def test_input_attributes_report_thought_parts_as_reasoning(): + """A replayed thought in the history keeps its own part type too.""" + content = types.Content( + role='model', + parts=[ + types.Part(text='earlier reasoning', thought=True), + types.Part(text='earlier answer'), + ], + ) + + attributes = _request_attributes( + LlmRequest(model='some-model', contents=[content]) + ) + + assert attributes[GEN_AI_INPUT_MESSAGES] == [{ + 'role': 'assistant', + 'parts': [ + {'content': 'earlier reasoning', 'type': 'reasoning'}, + {'content': 'earlier answer', 'type': 'text'}, + ], + }] + + def test_unaggregated_chunks_are_reported_as_one_message(): """Chunks no aggregator assembled extend one message, ending at the last.""" details: dict = {}