Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/google/adk/telemetry/_experimental_semconv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
55 changes: 55 additions & 0 deletions tests/unittests/telemetry/test_experimental_semconv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down
Loading