Skip to content
Merged
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
47 changes: 46 additions & 1 deletion tests/telemetry/test_trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,13 @@ def _mock_span():
return span


def _make_part(text=None, function_call=None, function_response=None, inline_data=None):
def _make_part(text=None, function_call=None, function_response=None, inline_data=None, thought=False):
part = MagicMock()
part.text = text
part.function_call = function_call
part.function_response = function_response
part.inline_data = inline_data
part.thought = thought
return part


Expand Down Expand Up @@ -229,6 +230,31 @@ def test_with_new_message(self, mock_get_span):

span.set_attribute.assert_any_call("trpc.python.agent.runner.input", "hello\nworld")

@patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span")
def test_with_thought_parts(self, mock_get_span):
span = _mock_span()
mock_get_span.return_value = span
ctx = _make_invocation_context()

parts = [
_make_part(text="thinking...", thought=True),
_make_part(text="final answer"),
]
content = _make_content(parts=parts)

trace_runner(
app_name="app",
user_id="u",
session_id="s",
invocation_context=ctx,
new_message=content,
)

span.set_attribute.assert_any_call(
"trpc.python.agent.runner.input",
"<trace_think>thinking...</trace_think>\nfinal answer",
)

@patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span")
def test_with_none_text_parts(self, mock_get_span):
span = _mock_span()
Expand Down Expand Up @@ -544,6 +570,25 @@ def test_with_user_content(self, mock_get_span):

span.set_attribute.assert_any_call("trpc.python.agent.agent.input", "hello\n agent")

@patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span")
def test_with_thought_user_content(self, mock_get_span):
span = _mock_span()
mock_get_span.return_value = span

parts = [
_make_part(text="reasoning", thought=True),
_make_part(text="answer"),
]
user_content = _make_content(parts=parts)
ctx = _make_invocation_context(user_content=user_content)

trace_agent(ctx)

span.set_attribute.assert_any_call(
"trpc.python.agent.agent.input",
"<trace_think>reasoning</trace_think>\nanswer",
)

@patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span")
def test_without_user_content(self, mock_get_span):
span = _mock_span()
Expand Down
41 changes: 34 additions & 7 deletions trpc_agent_sdk/telemetry/_trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,30 @@ def get_trpc_agent_span_name() -> str:
return _trpc_agent_span_name


def _join_parts_with_thought_tag(parts) -> str:
"""Join part texts, wrapping thought parts in <trace_think> tags.

Parts with ``thought=True`` (model reasoning/thinking) are wrapped in
``<trace_think>...</trace_think>`` tags so downstream consumers (e.g.
zhiyan-llm exporter) can distinguish and optionally strip them.

Args:
parts: A sequence of Part objects with ``text`` and optional
``thought`` attributes.

Returns:
The joined text string with thought parts tagged.
"""
result = []
for part in parts:
text = part.text or ""
if getattr(part, "thought", False):
result.append(f"<trace_think>{text}</trace_think>")
else:
result.append(text)
return "\n".join(result)


def _safe_json_serialize(obj) -> str:
"""Convert any Python object to a JSON-serializable type or string.

Expand Down Expand Up @@ -116,11 +140,11 @@ def trace_runner(
span.set_attribute(f"{_trpc_agent_span_name}.runner.session_id", session_id)
input_str = ""
if new_message and new_message.parts:
input_str = "\n".join([part.text or "" for part in new_message.parts])
input_str = _join_parts_with_thought_tag(new_message.parts)
span.set_attribute(f"{_trpc_agent_span_name}.runner.input", input_str)
output_str = ""
if last_event and last_event.content and last_event.content.parts:
output_str = "\n".join([part.text or "" for part in last_event.content.parts])
output_str = _join_parts_with_thought_tag(last_event.content.parts)
span.set_attribute(f"{_trpc_agent_span_name}.runner.output", output_str)

# Set state attributes for begin and end
Expand Down Expand Up @@ -181,15 +205,15 @@ def trace_cancellation(
# Input (prefixed with [CANCELLED] marker)
input_str = ""
if new_message and new_message.parts:
input_str += "\n".join([part.text or "" for part in new_message.parts])
input_str += _join_parts_with_thought_tag(new_message.parts)
span.set_attribute(f"{_trpc_agent_span_name}.runner.input", input_str)

# Output (prefixed with [CANCELLED] marker, includes partial text or last event)
output_str = "[CANCELLED]\n"
if partial_text:
output_str += partial_text
elif last_event and last_event.content and last_event.content.parts:
output_str += "\n".join([part.text or "" for part in last_event.content.parts])
output_str += _join_parts_with_thought_tag(last_event.content.parts)
span.set_attribute(f"{_trpc_agent_span_name}.runner.output", output_str)

# Cancellation-specific attributes
Expand Down Expand Up @@ -245,11 +269,14 @@ def trace_agent(
for content in override_messages:
if content and content.parts:
for part in content.parts:
if part.text and not part.thought:
text_parts.append(part.text)
if part.text:
if getattr(part, "thought", False):
text_parts.append(f"<trace_think>{part.text}</trace_think>")
else:
text_parts.append(part.text)
input_str = "\n".join(text_parts)
elif invocation_context.user_content and invocation_context.user_content.parts:
input_str = "\n".join([part.text or "" for part in invocation_context.user_content.parts])
input_str = _join_parts_with_thought_tag(invocation_context.user_content.parts)
span.set_attribute(f"{_trpc_agent_span_name}.agent.input", input_str)

span.set_attribute(f"{_trpc_agent_span_name}.agent.output", agent_action)
Expand Down
Loading