Repository navigation
fix(qwen-agent): accept tuple inputs for messages and content #913
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b521a45
cc26826
09ecdc4
07a6e45
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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.""" | ||
| 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: | ||
|
|
@@ -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)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question(non-blocking): Is this needed?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 "" | ||
|
|
||
|
|
@@ -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 "" | ||
|
|
@@ -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") | ||
|
|
@@ -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") | ||
|
|
@@ -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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
| ) | ||
|
|
@@ -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(): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"} | ||
| ] | ||
There was a problem hiding this comment.
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() -> booldoesn’t narrow the callers’ union types, causing pyright errors. UseTypeGuard[Sequence[Any]]for positive checks, and normalize converter inputs separately becauseTypeGuarddoesn’t narrow false branches:Then iterate over
_as_message_sequence(messages)instead of reassigningmessages. Could you verify withuv run tox -e typecheck?There was a problem hiding this comment.
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.