From b521a459ec4f72c561aee78832e6e0ef45506e16 Mon Sep 17 00:00:00 2001 From: Soumyajit Ghosh Date: Sun, 18 Oct 2026 02:27:16 +0530 Subject: [PATCH 1/4] fix(qwen-agent): accept tuple inputs for messages and content Update qwen-agent conversion helpers to accept tuples for messages and content parts. Fixes #904 Signed-off-by: Soumyajit Ghosh --- .../.changelog/904.fixed | 1 + .../instrumentation/genai/qwen_agent/utils.py | 25 ++-- .../tests/test_agent.py | 135 +++++++++++++++++- 3 files changed, 149 insertions(+), 12 deletions(-) create mode 100644 instrumentation/opentelemetry-instrumentation-genai-qwen-agent/.changelog/904.fixed diff --git a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/.changelog/904.fixed b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/.changelog/904.fixed new file mode 100644 index 000000000..18d669fb4 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/.changelog/904.fixed @@ -0,0 +1 @@ +Capture qwen-agent messages, content parts, and tool calls provided as tuples. diff --git a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/utils.py b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/utils.py index ae551d96c..5bced26d0 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/utils.py +++ b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/utils.py @@ -8,6 +8,7 @@ import json import logging +from collections.abc import Sequence from typing import TYPE_CHECKING, Any, cast from opentelemetry.util.genai.handler import TelemetryHandler @@ -62,14 +63,16 @@ 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 isinstance(content, (list, tuple)): texts: list[str] = [] for item in content: text = getattr(item, "text", None) @@ -80,7 +83,7 @@ def _extract_content_text(content: str | list[QwenContentItem] | None) -> str: def find_tool_call_id( - messages: list[QwenMessage] | None, tool_name: str + messages: Sequence[QwenMessage] | None, tool_name: str ) -> str | None: """Best-effort lookup of the current tool call's function id. @@ -89,7 +92,7 @@ def find_tool_call_id( ``tool_name`` that has no function-result message answering its ``function_id`` yet. """ - if not isinstance(messages, list): + if not isinstance(messages, (list, tuple)): return None responded: set[str] = set() @@ -141,13 +144,13 @@ 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: return [] - if not isinstance(messages, list): + if not isinstance(messages, (list, tuple)): messages = [messages] input_messages: list[InputMessage] = [] @@ -186,13 +189,13 @@ def convert_to_input_messages( def convert_to_output_messages( - messages: QwenMessage | list[QwenMessage] | None, + messages: QwenMessage | Sequence[QwenMessage] | None, ) -> list[OutputMessage]: """Convert qwen-agent response messages to GenAI OutputMessage objects.""" if not messages: return [] - if not isinstance(messages, list): + if not isinstance(messages, (list, tuple)): messages = [messages] output_messages: list[OutputMessage] = [] @@ -231,7 +234,7 @@ def convert_to_output_messages( def convert_to_final_output_messages( - messages: QwenMessage | list[QwenMessage] | None, + messages: QwenMessage | Sequence[QwenMessage] | None, ) -> list[OutputMessage]: """Convert only the final qwen-agent answer to OutputMessage objects. @@ -242,7 +245,7 @@ def convert_to_final_output_messages( if not messages: return [] - if not isinstance(messages, list): + if not isinstance(messages, (list, tuple)): messages = [messages] for msg in reversed(messages): @@ -268,7 +271,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) diff --git a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/test_agent.py b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/test_agent.py index 201eba8e1..b00a8f75e 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/test_agent.py +++ b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/test_agent.py @@ -12,9 +12,14 @@ 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, + convert_to_output_messages, + find_tool_call_id, +) from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAIAttributes, ) @@ -395,3 +400,131 @@ 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(): + """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_convert_to_output_messages_handles_tuples(): + """convert_to_output_messages accepts tuples of messages and tuple content items.""" + messages = ( + Message( + role="assistant", + content=(ContentItem(text="Thinking"), ContentItem(text="done")), + ), + Message( + role="assistant", + content="", + function_call=FunctionCall(name="calc", arguments='{"op": "add"}'), + ), + ) + result = convert_to_output_messages(messages) + assert len(result) == 2 + assert result[0].role == "assistant" + assert result[0].finish_reason == "stop" + assert result[0].parts[0].content == "Thinking\ndone" + assert result[1].role == "assistant" + assert result[1].finish_reason == "tool_call" + assert result[1].parts[0].name == "calc" + + +def test_find_tool_call_id_handles_tuples(): + """find_tool_call_id resolves pending function id when messages are passed as a tuple.""" + history = ( + Message(role="user", content="run tool"), + Message( + role="assistant", + content="", + function_call=FunctionCall(name="lookup", arguments='{"id": 42}'), + extra={"function_id": "call_lookup_42"}, + ), + ) + assert find_tool_call_id(history, "lookup") == "call_lookup_42" + + answered = history + ( + Message( + role="function", + name="lookup", + content="result", + extra={"function_id": "call_lookup_42"}, + ), + ) + assert find_tool_call_id(answered, "lookup") is None + + +def test_agent_run_with_tuple_messages(span_exporter, instrument_with_content): + """Agent.run() handles messages and yielded responses provided as tuples.""" + agent = _StubAgent.create(name="TupleAgentBot", llm=_StubLLM()) + + input_messages = ( + Message( + role="user", + content=( + ContentItem(text="Hello"), + ContentItem(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"} + ] From cc26826e53d1691abeb6b56509d64e727075fcc2 Mon Sep 17 00:00:00 2001 From: Soumyajit Ghosh Date: Sun, 18 Oct 2026 02:48:13 +0530 Subject: [PATCH 2/4] fix(qwen-agent): handle general sequences and dict content items Address review feedback: - Use _is_sequence across conversion helpers to support general Sequence types like UserList. - Use _field_value in _extract_content_text to read text from dictionary content items. - Add regression tests covering dict content items and UserList sequences. Signed-off-by: Soumyajit Ghosh --- .../instrumentation/genai/qwen_agent/utils.py | 25 ++-- .../tests/test_agent.py | 129 ++++++++++++++++++ 2 files changed, 145 insertions(+), 9 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/utils.py b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/utils.py index 5bced26d0..dc388b554 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/utils.py +++ b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/utils.py @@ -8,7 +8,7 @@ import json import logging -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any, cast from opentelemetry.util.genai.handler import TelemetryHandler @@ -35,6 +35,13 @@ _logger = logging.getLogger(__name__) +def _is_sequence(val: object) -> bool: + """Check if a value is a non-string, non-mapping sequence.""" + return isinstance(val, Sequence) and not isinstance( + val, (str, bytes, bytearray, Mapping) + ) + + def _field_value(value: Any, *names: str) -> Any: """Read the first present field from a dict or SDK object.""" if value is None: @@ -72,12 +79,12 @@ def _extract_content_text( """ if isinstance(content, str): return content - if isinstance(content, (list, tuple)): + 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)) return "\n".join(texts) return str(content) if content else "" @@ -92,7 +99,7 @@ def find_tool_call_id( ``tool_name`` that has no function-result message answering its ``function_id`` yet. """ - if not isinstance(messages, (list, tuple)): + if not _is_sequence(messages): return None responded: set[str] = set() @@ -146,11 +153,11 @@ def _tool_call_response_id(msg: Any) -> str: def convert_to_input_messages( messages: QwenMessage | Sequence[QwenMessage] | None, ) -> list[InputMessage]: - """Convert a qwen-agent Message list to GenAI InputMessage objects.""" + """Convert a qwen-agent Message sequence to GenAI InputMessage objects.""" if not messages: return [] - if not isinstance(messages, (list, tuple)): + if not _is_sequence(messages): messages = [messages] input_messages: list[InputMessage] = [] @@ -195,7 +202,7 @@ def convert_to_output_messages( if not messages: return [] - if not isinstance(messages, (list, tuple)): + if not _is_sequence(messages): messages = [messages] output_messages: list[OutputMessage] = [] @@ -245,7 +252,7 @@ def convert_to_final_output_messages( if not messages: return [] - if not isinstance(messages, (list, tuple)): + if not _is_sequence(messages): messages = [messages] for msg in reversed(messages): diff --git a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/test_agent.py b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/test_agent.py index b00a8f75e..bfeb22ffe 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/test_agent.py +++ b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/test_agent.py @@ -6,6 +6,7 @@ import inspect import json +from collections import UserList from unittest.mock import patch import pytest @@ -16,6 +17,7 @@ from qwen_agent.tools.base import BaseTool, register_tool from opentelemetry.instrumentation.genai.qwen_agent.utils import ( + convert_to_final_output_messages, convert_to_input_messages, convert_to_output_messages, find_tool_call_id, @@ -528,3 +530,130 @@ def fake_run(messages, **kwargs): 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_conversion_helpers_handle_user_list_sequences(): + """General Sequence implementations like UserList are supported across helpers.""" + input_msgs = UserList( + [ + Message( + role="user", + content=UserList( + [ContentItem(text="Hello"), {"text": "from UserList"}] + ), + ), + Message( + 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" + + output_msgs = UserList( + [ + Message( + role="assistant", + content=UserList([{"text": "Part A"}, {"text": "Part B"}]), + ), + ] + ) + converted_output = convert_to_output_messages(output_msgs) + assert len(converted_output) == 1 + assert converted_output[0].role == "assistant" + assert converted_output[0].parts[0].content == "Part A\nPart B" + + final_output = convert_to_final_output_messages(output_msgs) + assert len(final_output) == 1 + assert final_output[0].parts[0].content == "Part A\nPart B" + + tool_history = UserList( + [ + Message(role="user", content="fetch data"), + Message( + role="assistant", + content="", + function_call=FunctionCall(name="fetch", arguments="{}"), + extra={"function_id": "call_seq_1"}, + ), + ] + ) + assert find_tool_call_id(tool_history, "fetch") == "call_seq_1" + + answered_history = UserList( + list(tool_history) + + [ + Message( + role="function", + name="fetch", + content="result", + extra={"function_id": "call_seq_1"}, + ) + ] + ) + assert find_tool_call_id(answered_history, "fetch") is None + + +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( + [ + Message( + role="user", + content=UserList([{"text": "Hello UserList"}]), + ), + ] + ) + response_messages = UserList( + [ + 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"} + ] From 09ecdc4776d378f29b73787a3eb3890d4a93aff7 Mon Sep 17 00:00:00 2001 From: Soumyajit Ghosh Date: Mon, 19 Oct 2026 08:39:59 +0530 Subject: [PATCH 3/4] fix(qwen-agent): scope tuple inputs, normalize type narrowing, and rename changelog to 913 Address review feedback from @eternalcuriouslearner: - Implement _as_message_sequence helper and TypeGuard[Sequence[Any]] on _is_sequence to resolve pyright union narrowing errors cleanly. - Scope tuple inputs to input messages and content, keeping output converters and tool call lookup scoped to standard lists. - Update integration and unit tests to pass tuple/UserList inputs with raw dictionary messages so content reaches instrumentation unchanged, with _run() yielding normal lists. - Rename changelog entry from 904.fixed to 913.fixed. Signed-off-by: Soumyajit Ghosh --- .../.changelog/904.fixed | 1 - .../.changelog/913.fixed | 1 + .../instrumentation/genai/qwen_agent/utils.py | 43 +++-- .../tests/test_agent.py | 149 ++++-------------- 4 files changed, 54 insertions(+), 140 deletions(-) delete mode 100644 instrumentation/opentelemetry-instrumentation-genai-qwen-agent/.changelog/904.fixed create mode 100644 instrumentation/opentelemetry-instrumentation-genai-qwen-agent/.changelog/913.fixed diff --git a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/.changelog/904.fixed b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/.changelog/904.fixed deleted file mode 100644 index 18d669fb4..000000000 --- a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/.changelog/904.fixed +++ /dev/null @@ -1 +0,0 @@ -Capture qwen-agent messages, content parts, and tool calls provided as tuples. diff --git a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/.changelog/913.fixed b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/.changelog/913.fixed new file mode 100644 index 000000000..01630f0bd --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/.changelog/913.fixed @@ -0,0 +1 @@ +Capture qwen-agent messages and content parts provided as tuples. diff --git a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/utils.py b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/utils.py index dc388b554..745306090 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/utils.py +++ b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/utils.py @@ -9,7 +9,7 @@ import json import logging from collections.abc import Mapping, Sequence -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, TypeGuard, cast from opentelemetry.util.genai.handler import TelemetryHandler from opentelemetry.util.genai.invocation import LocalAgentInvocation @@ -35,13 +35,26 @@ _logger = logging.getLogger(__name__) -def _is_sequence(val: object) -> bool: +def _is_sequence(val: object) -> TypeGuard[Sequence[Any]]: """Check if a value is a non-string, non-mapping sequence.""" 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 isinstance(messages, Sequence) and not isinstance( + messages, (str, bytes, bytearray, Mapping) + ): + return 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: @@ -90,7 +103,7 @@ def _extract_content_text( def find_tool_call_id( - messages: Sequence[QwenMessage] | None, tool_name: str + messages: list[QwenMessage] | None, tool_name: str ) -> str | None: """Best-effort lookup of the current tool call's function id. @@ -99,7 +112,7 @@ def find_tool_call_id( ``tool_name`` that has no function-result message answering its ``function_id`` yet. """ - if not _is_sequence(messages): + if not isinstance(messages, list): return None responded: set[str] = set() @@ -154,14 +167,12 @@ def convert_to_input_messages( messages: QwenMessage | Sequence[QwenMessage] | None, ) -> list[InputMessage]: """Convert a qwen-agent Message sequence to GenAI InputMessage objects.""" - if not messages: + message_sequence = _as_message_sequence(messages) + if not message_sequence: return [] - if not _is_sequence(messages): - 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 "" @@ -196,17 +207,16 @@ def convert_to_input_messages( def convert_to_output_messages( - messages: QwenMessage | Sequence[QwenMessage] | None, + messages: QwenMessage | list[QwenMessage] | None, ) -> list[OutputMessage]: """Convert qwen-agent response messages to GenAI OutputMessage objects.""" if not messages: return [] - if not _is_sequence(messages): - 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") @@ -241,7 +251,7 @@ def convert_to_output_messages( def convert_to_final_output_messages( - messages: QwenMessage | Sequence[QwenMessage] | None, + messages: QwenMessage | list[QwenMessage] | None, ) -> list[OutputMessage]: """Convert only the final qwen-agent answer to OutputMessage objects. @@ -252,10 +262,9 @@ def convert_to_final_output_messages( if not messages: return [] - if not _is_sequence(messages): - 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") diff --git a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/test_agent.py b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/test_agent.py index bfeb22ffe..0bb32c494 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/test_agent.py +++ b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/test_agent.py @@ -17,10 +17,7 @@ from qwen_agent.tools.base import BaseTool, register_tool from opentelemetry.instrumentation.genai.qwen_agent.utils import ( - convert_to_final_output_messages, convert_to_input_messages, - convert_to_output_messages, - find_tool_call_id, ) from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAIAttributes, @@ -443,69 +440,22 @@ def test_convert_to_input_messages_handles_tuples(): assert result[3].parts[0].content == "Final note" -def test_convert_to_output_messages_handles_tuples(): - """convert_to_output_messages accepts tuples of messages and tuple content items.""" - messages = ( - Message( - role="assistant", - content=(ContentItem(text="Thinking"), ContentItem(text="done")), - ), - Message( - role="assistant", - content="", - function_call=FunctionCall(name="calc", arguments='{"op": "add"}'), - ), - ) - result = convert_to_output_messages(messages) - assert len(result) == 2 - assert result[0].role == "assistant" - assert result[0].finish_reason == "stop" - assert result[0].parts[0].content == "Thinking\ndone" - assert result[1].role == "assistant" - assert result[1].finish_reason == "tool_call" - assert result[1].parts[0].name == "calc" - - -def test_find_tool_call_id_handles_tuples(): - """find_tool_call_id resolves pending function id when messages are passed as a tuple.""" - history = ( - Message(role="user", content="run tool"), - Message( - role="assistant", - content="", - function_call=FunctionCall(name="lookup", arguments='{"id": 42}'), - extra={"function_id": "call_lookup_42"}, - ), - ) - assert find_tool_call_id(history, "lookup") == "call_lookup_42" - - answered = history + ( - Message( - role="function", - name="lookup", - content="result", - extra={"function_id": "call_lookup_42"}, - ), - ) - assert find_tool_call_id(answered, "lookup") is None - - def test_agent_run_with_tuple_messages(span_exporter, instrument_with_content): - """Agent.run() handles messages and yielded responses provided as tuples.""" + """Agent.run() handles messages and content provided as tuples.""" agent = _StubAgent.create(name="TupleAgentBot", llm=_StubLLM()) input_messages = ( - Message( - role="user", - content=( - ContentItem(text="Hello"), - ContentItem(text="from tuple"), + { + "role": "user", + "content": ( + {"text": "Hello"}, + {"text": "from tuple"}, ), - ), + }, ) - response_messages = ( + response_messages = [ Message(role="assistant", content="Answer from tuple"), - ) + ] def fake_run(messages, **kwargs): yield response_messages @@ -549,20 +499,20 @@ def test_convert_to_input_messages_handles_raw_dict_tuple_content(): assert result[1].parts[0].content == "Second question" -def test_conversion_helpers_handle_user_list_sequences(): - """General Sequence implementations like UserList are supported across helpers.""" +def test_convert_to_input_messages_handles_user_list(): + """convert_to_input_messages handles general Sequence implementations like UserList.""" input_msgs = UserList( [ - Message( - role="user", - content=UserList( - [ContentItem(text="Hello"), {"text": "from UserList"}] + { + "role": "user", + "content": UserList( + [{"text": "Hello"}, {"text": "from UserList"}] ), - ), - Message( - role="assistant", - content="Response text", - ), + }, + { + "role": "assistant", + "content": "Response text", + }, ] ) converted_input = convert_to_input_messages(input_msgs) @@ -572,49 +522,6 @@ def test_conversion_helpers_handle_user_list_sequences(): assert converted_input[1].role == "assistant" assert converted_input[1].parts[0].content == "Response text" - output_msgs = UserList( - [ - Message( - role="assistant", - content=UserList([{"text": "Part A"}, {"text": "Part B"}]), - ), - ] - ) - converted_output = convert_to_output_messages(output_msgs) - assert len(converted_output) == 1 - assert converted_output[0].role == "assistant" - assert converted_output[0].parts[0].content == "Part A\nPart B" - - final_output = convert_to_final_output_messages(output_msgs) - assert len(final_output) == 1 - assert final_output[0].parts[0].content == "Part A\nPart B" - - tool_history = UserList( - [ - Message(role="user", content="fetch data"), - Message( - role="assistant", - content="", - function_call=FunctionCall(name="fetch", arguments="{}"), - extra={"function_id": "call_seq_1"}, - ), - ] - ) - assert find_tool_call_id(tool_history, "fetch") == "call_seq_1" - - answered_history = UserList( - list(tool_history) - + [ - Message( - role="function", - name="fetch", - content="result", - extra={"function_id": "call_seq_1"}, - ) - ] - ) - assert find_tool_call_id(answered_history, "fetch") is None - def test_agent_run_with_user_list_messages( span_exporter, instrument_with_content @@ -624,17 +531,15 @@ def test_agent_run_with_user_list_messages( input_messages = UserList( [ - Message( - role="user", - content=UserList([{"text": "Hello UserList"}]), - ), - ] - ) - response_messages = UserList( - [ - Message(role="assistant", content="Answer from 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 From 07a6e45dbe4f9a0b045469f895eac42dae7eef98 Mon Sep 17 00:00:00 2001 From: Soumyajit Ghosh Date: Tue, 20 Oct 2026 13:44:31 +0530 Subject: [PATCH 4/4] refactor(qwen-agent): use _is_sequence helper in _as_message_sequence Signed-off-by: Soumyajit Ghosh --- .../opentelemetry/instrumentation/genai/qwen_agent/utils.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/utils.py b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/utils.py index 745306090..3019144f9 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/utils.py +++ b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/utils.py @@ -48,10 +48,8 @@ def _as_message_sequence( """Normalize input messages into a sequence without copying.""" if messages is None: return () - if isinstance(messages, Sequence) and not isinstance( - messages, (str, bytes, bytearray, Mapping) - ): - return messages + if _is_sequence(messages): + return cast("Sequence[QwenMessage]", messages) return (cast("QwenMessage", messages),)