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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Capture qwen-agent messages and content parts provided as tuples.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@

import json
import logging
from typing import TYPE_CHECKING, Any, cast
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, TypeGuard, cast

from opentelemetry.util.genai.handler import TelemetryHandler
from opentelemetry.util.genai.invocation import LocalAgentInvocation
Expand All @@ -34,6 +35,24 @@
_logger = logging.getLogger(__name__)


def _is_sequence(val: object) -> TypeGuard[Sequence[Any]]:
"""Check if a value is a non-string, non-mapping sequence."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(blocking): Could we fix the type narrowing? _is_sequence() -> bool doesn’t narrow the callers’ union types, causing pyright errors. Use TypeGuard[Sequence[Any]] for positive checks, and normalize converter inputs separately because TypeGuard doesn’t narrow false branches:

def _as_message_sequence(
    messages: QwenMessage | Sequence[QwenMessage] | None,
) -> Sequence[QwenMessage]:
    if messages is None:
        return ()
    if isinstance(messages, Sequence):
        return messages
    return (messages,)

Then iterate over _as_message_sequence(messages) instead of reassigning messages. Could you verify with uv run tox -e typecheck?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in commit acfe76f: annotated _is_sequence with TypeGuard[Sequence[Any]] and introduced _as_message_sequence to normalize QwenMessage | Sequence[QwenMessage] | None inputs without false-branch narrowing issues. Verified with pyright across the package with zero errors.

return isinstance(val, Sequence) and not isinstance(
val, (str, bytes, bytearray, Mapping)
)


def _as_message_sequence(
messages: QwenMessage | Sequence[QwenMessage] | None,
) -> Sequence[QwenMessage]:
"""Normalize input messages into a sequence without copying."""
if messages is None:
return ()
if _is_sequence(messages):
return cast("Sequence[QwenMessage]", messages)
return (cast("QwenMessage", messages),)


def _field_value(value: Any, *names: str) -> Any:
"""Read the first present field from a dict or SDK object."""
if value is None:
Expand Down Expand Up @@ -62,19 +81,21 @@ def _field_value(value: Any, *names: str) -> Any:
return None


def _extract_content_text(content: str | list[QwenContentItem] | None) -> str:
def _extract_content_text(
content: str | Sequence[QwenContentItem] | None,
) -> str:
"""Extract text from a qwen-agent Message content field.

Content can be a plain string or a list of ContentItem.
"""
if isinstance(content, str):
return content
if isinstance(content, list):
if _is_sequence(content):
texts: list[str] = []
for item in content:
text = getattr(item, "text", None)
text = _field_value(item, "text")
if text is not None:
texts.append(text)
texts.append(str(text))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(non-blocking): Is this needed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept str(text) as defensive conversion in commit acfe76f so non-string text values from raw dictionary items are handled consistently without raising in str.join, verified with pyright with zero errors.

return "\n".join(texts)
return str(content) if content else ""

Expand Down Expand Up @@ -141,17 +162,15 @@ def _tool_call_response_id(msg: Any) -> str:


def convert_to_input_messages(
messages: QwenMessage | list[QwenMessage] | None,
messages: QwenMessage | Sequence[QwenMessage] | None,
) -> list[InputMessage]:
"""Convert a qwen-agent Message list to GenAI InputMessage objects."""
if not messages:
"""Convert a qwen-agent Message sequence to GenAI InputMessage objects."""
message_sequence = _as_message_sequence(messages)
if not message_sequence:
return []

if not isinstance(messages, list):
messages = [messages]

input_messages: list[InputMessage] = []
for msg in messages:
for msg in message_sequence:
try:
role = _field_value(msg, "role") or Role.USER.value
content = _field_value(msg, "content") or ""
Expand Down Expand Up @@ -192,11 +211,10 @@ def convert_to_output_messages(
if not messages:
return []

if not isinstance(messages, list):
messages = [messages]
msg_list = messages if isinstance(messages, list) else [messages]

output_messages: list[OutputMessage] = []
for msg in messages:
for msg in msg_list:
try:
content = _field_value(msg, "content") or ""
function_call = _field_value(msg, "function_call")
Expand Down Expand Up @@ -242,10 +260,9 @@ def convert_to_final_output_messages(
if not messages:
return []

if not isinstance(messages, list):
messages = [messages]
msg_list = messages if isinstance(messages, list) else [messages]

for msg in reversed(messages):
for msg in reversed(msg_list):
try:
role = _field_value(msg, "role") or Role.ASSISTANT.value
function_call = _field_value(msg, "function_call")
Expand All @@ -268,7 +285,7 @@ def convert_to_final_output_messages(
def create_agent_invocation(
handler: TelemetryHandler,
agent_instance: Any,
messages: QwenMessage | list[QwenMessage] | None,
messages: QwenMessage | Sequence[QwenMessage] | None,
) -> LocalAgentInvocation:
"""Create and start an AgentInvocation for Agent.run()."""
llm_instance = getattr(agent_instance, "llm", None)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@

import inspect
import json
from collections import UserList
from unittest.mock import patch

import pytest
from qwen_agent.agent import Agent
from qwen_agent.agents import Assistant
from qwen_agent.llm import base as qwen_llm_base
from qwen_agent.llm.schema import FunctionCall, Message
from qwen_agent.llm.schema import ContentItem, FunctionCall, Message
from qwen_agent.tools.base import BaseTool, register_tool

from opentelemetry.instrumentation.genai.qwen_agent.utils import (
convert_to_input_messages,
)
from opentelemetry.semconv._incubating.attributes import (
gen_ai_attributes as GenAIAttributes,
)
Expand Down Expand Up @@ -395,3 +399,166 @@ def fake_run(self, messages, **kwargs):
assert len(child_spans) == 1
assert child_spans[0].parent is not None
assert child_spans[0].parent.span_id == parent_spans[0].context.span_id


def test_convert_to_input_messages_handles_tuples():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(blocking): Could we make the integration tests pass tuple inputs to Agent.run(), have _run() yield normal lists, and assert both captured input and output? For tuple/UserList content coverage, could we use raw dictionary messages so the content reaches instrumentation unchanged, since Message(...) converts those containers to lists during validation?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in commit acfe76f: integration tests now pass tuple and UserList inputs with raw dictionary messages to Agent.run(), ensuring container shapes reach instrumentation intact without being converted by Message(...) validation. _run() yields standard lists, and assertions verify both captured input messages and final output messages.

"""convert_to_input_messages accepts tuples of messages and tuple content items."""
messages = (
Message(
role="user",
content=(ContentItem(text="Hello"), ContentItem(text="world")),
),
Message(
role="assistant",
content="",
function_call=FunctionCall(
name="get_weather", arguments='{"city": "Paris"}'
),
),
Message(
role="function",
name="get_weather",
content="Sunny, 20C",
extra={"function_id": "call_1"},
),
{
"role": "user",
"content": "Final note",
},
)
result = convert_to_input_messages(messages)
assert len(result) == 4
assert result[0].role == "user"
assert len(result[0].parts) == 1
assert result[0].parts[0].content == "Hello\nworld"
assert result[1].role == "assistant"
assert result[1].parts[0].name == "get_weather"
assert result[1].parts[0].arguments == {"city": "Paris"}
assert result[2].role == "function"
assert result[2].parts[0].response == "Sunny, 20C"
assert result[3].role == "user"
assert result[3].parts[0].content == "Final note"


def test_agent_run_with_tuple_messages(span_exporter, instrument_with_content):
"""Agent.run() handles messages and content provided as tuples."""
agent = _StubAgent.create(name="TupleAgentBot", llm=_StubLLM())

input_messages = (
{
"role": "user",
"content": (
{"text": "Hello"},
{"text": "from tuple"},
),
},
)
response_messages = [
Message(role="assistant", content="Answer from tuple"),
]

def fake_run(messages, **kwargs):
yield response_messages

with patch.object(_StubAgent, "_run", side_effect=fake_run):
list(agent.run(input_messages))

agent_spans = _spans_named(span_exporter, "invoke_agent TupleAgentBot")
assert len(agent_spans) == 1
attrs = dict(agent_spans[0].attributes or {})

input_data = json.loads(attrs[GenAIAttributes.GEN_AI_INPUT_MESSAGES])
assert len(input_data) == 1
assert input_data[0]["role"] == "user"
assert input_data[0]["parts"] == [
{"content": "Hello\nfrom tuple", "type": "text"}
]

output_data = json.loads(attrs[GenAIAttributes.GEN_AI_OUTPUT_MESSAGES])
assert len(output_data) == 1
assert output_data[0]["role"] == "assistant"
assert output_data[0]["parts"] == [
{"content": "Answer from tuple", "type": "text"}
]


def test_convert_to_input_messages_handles_raw_dict_tuple_content():
"""convert_to_input_messages extracts text from raw dictionary content items in a tuple."""
messages = (
{
"role": "user",
"content": ({"text": "Hello"}, {"text": "raw dict world"}),
},
Message(role="user", content=({"text": "Second question"},)),
)
result = convert_to_input_messages(messages)
assert len(result) == 2
assert result[0].role == "user"
assert result[0].parts[0].content == "Hello\nraw dict world"
assert result[1].role == "user"
assert result[1].parts[0].content == "Second question"


def test_convert_to_input_messages_handles_user_list():
"""convert_to_input_messages handles general Sequence implementations like UserList."""
input_msgs = UserList(
[
{
"role": "user",
"content": UserList(
[{"text": "Hello"}, {"text": "from UserList"}]
),
},
{
"role": "assistant",
"content": "Response text",
},
]
)
converted_input = convert_to_input_messages(input_msgs)
assert len(converted_input) == 2
assert converted_input[0].role == "user"
assert converted_input[0].parts[0].content == "Hello\nfrom UserList"
assert converted_input[1].role == "assistant"
assert converted_input[1].parts[0].content == "Response text"


def test_agent_run_with_user_list_messages(
span_exporter, instrument_with_content
):
"""Agent.run() handles messages provided as general Sequence like UserList."""
agent = _StubAgent.create(name="UserListAgentBot", llm=_StubLLM())

input_messages = UserList(
[
{
"role": "user",
"content": UserList([{"text": "Hello UserList"}]),
},
]
)
response_messages = [
Message(role="assistant", content="Answer from UserList"),
]

def fake_run(messages, **kwargs):
yield response_messages

with patch.object(_StubAgent, "_run", side_effect=fake_run):
list(agent.run(input_messages))

agent_spans = _spans_named(span_exporter, "invoke_agent UserListAgentBot")
assert len(agent_spans) == 1
attrs = dict(agent_spans[0].attributes or {})

input_data = json.loads(attrs[GenAIAttributes.GEN_AI_INPUT_MESSAGES])
assert len(input_data) == 1
assert input_data[0]["parts"] == [
{"content": "Hello UserList", "type": "text"}
]

output_data = json.loads(attrs[GenAIAttributes.GEN_AI_OUTPUT_MESSAGES])
assert len(output_data) == 1
assert output_data[0]["parts"] == [
{"content": "Answer from UserList", "type": "text"}
]
Loading