feat(qwen_agent): add OpenInference instrumentation for Qwen-Agent - #3742
jimbobbennett wants to merge 3 commits into
Conversation
| import gc | ||
| import json | ||
| from importlib.metadata import entry_points | ||
| from typing import Any, Dict, List, Optional, Sequence | ||
|
|
||
| import pytest |
There was a problem hiding this comment.
Testing convention: scripted backend used instead of VCR cassettes
The Python CLAUDE.md mandates the following testing approach:
"Uses pytest-recording (vcrpy) — cassettes are committed next to the test module (
tests/openinference/instrumentation/<name>/cassettes/) so CI needs no API key. Mark tests with@pytest.mark.vcr. Strip request/response headers in thevcr_configfixture so credentials never land in cassettes."
This PR instead uses a FakeChatModel scripted backend registered with @register_llm("oi_fake"). There are no @pytest.mark.vcr marks, no committed cassettes directory, and pytest-recording/vcrpy are absent from test-requirements.txt.
The standard approach is to record HTTP cassettes against the real SDK once and replay them in CI. For the other instrumentors in this repo (openai, langchain, llama-index, etc.) this approach ensures the instrumentation is verified against the real SDK wire format, not just a hand-rolled fake. Switching to VCR cassettes would give stronger confidence that the wrappers behave correctly end-to-end when the real BaseChatModel.chat dispatch path is used.
There was a problem hiding this comment.
This is the trade-off called out in the PR description, so let me give the numbers behind it. On current main, 13 of 38 Python instrumentor packages ship no cassettes — ag2, agentspec, autogen, cohere, groq, guardrails, mcp, openai-agents, pipecat, promptflow, strands-agents, vertexai and this one — and four of those (ag2, openai-agents, strands-agents, cohere) do not list pytest-recording at all. So it is a common approach rather than a deviation, though I agree cassettes are the stronger default where they fit.
One correction on the mechanism: the fake is not a hand-rolled substitute for BaseChatModel.chat. It registers through qwen-agent's own @register_llm and overrides only _chat_with_functions / _chat_stream / _chat_no_stream. The real chat() still runs — message normalisation, function-call mode selection, _preprocess_messages, the retry wrapper, _postprocess_messages_iterator, the response cache and the cumulative-streaming contract — which is precisely the dispatch path the wrapper wraps. That is also what makes it possible to test the DashScope-only token-usage path (Message.extra["model_service_info"]) without DashScope credentials, and to script things a cassette cannot easily produce: delta_stream=True fragments, a mid-stream KeyboardInterrupt, an abandoned stream, and two calls to the same tool in one response.
The genuine residual gap — already stated in the PR body — is that no test pins the real wire format, in particular DashScope's model_service_info shape. A cassette would close that and I am happy to add one; it needs a DASHSCOPE_API_KEY to record, which I do not have. The instrumentation has been exercised against a real OpenAI-compatible Qwen server end to end, with the span trees read back from both Arize AX and a local Phoenix, but that is manual verification rather than a committed cassette.
Leaving this open for a maintainer to decide whether to block on cassettes.
| def _agent_span_name_and_kind(instance: Any) -> Tuple[str, OpenInferenceSpanKindValues]: | ||
| class_name = instance.__class__.__name__ | ||
| if _is_memory(instance): | ||
| # Memory is an Agent subclass used for file management and RAG rather | ||
| # than for reasoning, so it reads better as a CHAIN. Its `retrieval` and | ||
| # `doc_parser` calls still surface as TOOL spans underneath. | ||
| return f"{class_name}.run", OpenInferenceSpanKindValues.CHAIN | ||
| name = getattr(instance, "name", None) or class_name | ||
| return f"{name}.run", OpenInferenceSpanKindValues.AGENT |
There was a problem hiding this comment.
Span naming: ad-hoc runtime strings instead of class names
The Python CLAUDE.md requires:
"Span names: name spans after the wrapped resource class (e.g.
Completions/AsyncCompletions), consistent with sibling instrumentors — never ad-hoc lowercase names, which break cross-provider span-name queries."
The AGENT path returns f"{name}.run" where name = getattr(instance, "name", None) or class_name. When instance.name is set (e.g. "docs-assistant", "weather-bot"), the span name becomes a user-supplied lowercase string like docs-assistant.run — not a stable, class-based name.
This is consistent with how _ToolCallWrapper names tool spans (f"{tool_name}.call", e.g. get_weather.call), but both deviate from the convention.
Suggested fix: use the class name unconditionally for the span name, while keeping instance.name available as an attribute if needed:
def _agent_span_name_and_kind(instance: Any) -> Tuple[str, OpenInferenceSpanKindValues]:
class_name = instance.__class__.__name__
if _is_memory(instance):
return f"{class_name}.run", OpenInferenceSpanKindValues.CHAIN
return f"{class_name}.run", OpenInferenceSpanKindValues.AGENTSimilarly for _ToolCallWrapper, prefer the tool class name over the runtime string tool_name.
There was a problem hiding this comment.
I would push back on this one, because the convention line is about LLM-client instrumentors and the agent-framework instrumentors in this repo ship the opposite.
smolagents/_wrappers.py:123 is byte-identical to what this PR does:
span_name = f"{getattr(agent, 'name', None) or agent.__class__.__name__}.run"crewai does the same with runtime values — f"{role}.kickoff", f"{role}.{task_name[:50]}.execute" (_event_listener.py:347,416) — and sibling tests assert lowercase runtime-named spans as shipped behaviour: crew.kickoff, writer.generate_reply, recipient.generate_reply, search_tool.run, scrape_website.run, search.run.
The convention example (Completions / AsyncCompletions) is an LLM client, where the wrapped resource genuinely is a class. For an agent framework the wrapped resource is a user-configured agent, and the class name is nearly always just Assistant: every agent in a Router or GroupChat would collapse to Assistant.run, making a multi-agent trace unreadable and defeating the cross-trace queries the rule is meant to protect. The class name is not lost — it is the fallback when the agent has no name.
Where the wrapped resource really is a class, this PR does follow the rule: LLM spans are f"{instance.__class__.__name__}.chat" (TextChatAtOAI.chat, QwenChatAtDS.chat), exactly like smolagents {class}.generate.
For tool spans, crewai uses f"{tool_name}.{method}" (_get_tool_span_name) and ag2 uses the runtime function name, so get_weather.call matches that precedent too. tool.name carries the same value as an attribute either way.
Happy to switch if a maintainer would rather have class-based names here, but it would make this package inconsistent with the other agent-framework instrumentors, so I have left it as-is pending that call.
Adds `openinference-instrumentation-qwen-agent`, which traces agent runs, model calls, tool calls and document retrieval in the Qwen-Agent framework. Three methods are wrapped with wrapt: - `Agent.run` -> AGENT span, or CHAIN for `Memory` (an `Agent` subclass used for file management and RAG rather than reasoning). `Agent.run_nonstream` is deliberately not wrapped because it calls `run` internally. - `BaseChatModel.chat` -> LLM span. Every backend routes through this one method, so DashScope, the OpenAI-compatible backends, Azure, `transformers` and OpenVINO are covered without duplication. - `Agent._call_tool` -> TOOL span, or RETRIEVER for the built-in `retrieval` tool, whose chunks are recorded as documents. `FnCallAgent` is the only subclass that overrides `_call_tool` and it delegates to `super()`, so wrapping the base method catches every tool call exactly once. Multi-agent setups need no extra configuration: `Router` and `GroupChat` call `run` on their members, so nested agents appear as child spans. Token counts are recorded only where Qwen-Agent exposes them — the DashScope backends, via `Message.extra["model_service_info"]` — and never aggregated onto AGENT spans, so trace-level totals are not double counted. `qwen_agent.utils.parallel_executor` submits to a ThreadPoolExecutor without copying the caller's context, which would leave the spans of agents fanned out by `ParallelDocQA` orphaned in a separate trace. The instrumentor swaps in a context-preserving executor and restores it on uninstrument. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…butes The multimodal and reasoning-content assertions hand-spelled `message_content.type`, `message_content.text` and `message_content.image.image.url` as string literals. python/CLAUDE.md requires semantic-convention keys to be built from the semconv classes, so use `MessageContentAttributes` and `ImageAttributes.IMAGE_URL` instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Agent._call_tool` swallows most tool exceptions and returns the error text, which was already covered, but re-raises `ToolServiceError` and `DocParserError`. Those reach the wrapper and are recorded on the span by `start_as_current_span`'s `record_exception` / `set_status_on_exception` defaults — ERROR status plus an exception event. Add a test so that behaviour is pinned rather than incidental. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e81d3bf to
1e99a4b
Compare
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
Adds
openinference-instrumentation-qwen-agent, tracing agent runs, model calls, tool calls and document retrieval in the Qwen-Agent framework. There was no Qwen-Agent instrumentor in any language.No tracking issue exists for this — happy to file one and link it if that's preferred.
What gets traced
Agent.runAGENT<agent name>.run.Agent.run_nonstreamis deliberately not wrapped — it callsruninternally, so wrapping both would double every agent span.Agent.runon aMemoryCHAINMemoryis anAgentsubclass used for file management and RAG rather than reasoning, andAssistant._runruns it on every turn.BaseChatModel.chatLLMtransformersand OpenVINO are covered with a single span and no duplication.Agent._call_toolTOOLFnCallAgentis the only subclass that overrides it and it delegates tosuper(), so wrapping the base method catches every tool call exactly once. Thetool_call.idis recovered from the message history, which is the only place Qwen-Agent exposes it.Agent._call_toolonretrievalRETRIEVERMulti-agent needs no configuration:
RouterandGroupChatcallrunon their members, so nested agents appear as child spans.Design decisions worth a look
Token counts are recorded only where Qwen-Agent exposes them. The DashScope backends stash the raw response on
Message.extra["model_service_info"]; the OpenAI-compatible backends discard it. They are never aggregated ontoAGENTspans, because trace totals are summed across every span in a trace (see #3164) and a duplicated count inflates them.There is a related gap worth knowing about: Qwen-Agent never sends
stream_options={"include_usage": true}andAgent._call_llmalways streams, so on an OpenAI-compatible backend an agent run has no token counts available from any span. Nothing is being dropped — there is nothing to record. The README documents this and advises against addingOpenAIInstrumentorpurely for token counts, since platforms that estimatellm.token_count.*server-side will then estimate both the framework span and the nested SDK span and count the same call twice.Thread context propagation.
qwen_agent/utils/parallel_executor.pysubmits to aThreadPoolExecutorwithout copying the caller's context, so the spans of agents fanned out byParallelDocQAbecome orphaned roots in a separate trace. The instrumentor swaps in a context-preserving executor — the same approachopeninference-instrumentation-smolagentsuses forlocal_python_executor— and restores it on uninstrument.Streaming.
Agent.runandchat(stream=True)both return generators that yield the cumulative message list. The chat stream is wrapped in awrapt.ObjectProxyper the repo's streaming convention;send/throw/closeare all intercepted so every way of driving the stream finalises the span, with__del__as a backstop for a stream that is abandoned un-iterated.Known limitations (also in the package README)
FnCallAgent._call_toolreturns early for an unregistered tool without reachingAgent._call_tool, so that case produces noTOOLspan.Agent._call_toolswallows tool exceptions and returns the error text as the result, so a failing tool yields a successfulTOOLspan whose output is the error message.document.score: Qwen-Agent computes relevance scores inBaseSearch.sort_by_scoresbut discards them inget_topk.llm.provideris set only formodel_type="azure". Qwen-Agent does not retain the base URL on the model instance, so an OpenAI-compatible endpoint cannot be attributed, and DashScope has no well-known provider value yet. The raw value is always emitted asqwen_agent.llm.model_type.qwen-agent==0.0.34importsnumpy,soundfile,tqdmandpython-dateutilat module load without declaring them, sotest-requirements.txtpins them explicitly.Testing
58 tests, passing on
py310-ci-qwen_agentandpy314-ci-qwen_agent, withruffandmypy --strictclean. Covers the required categories (suppress tracing, context attribute propagation,TraceConfigmasking) plus span hierarchy and single-trace assertions,run_nonstreamnot double-counting, abandoned and interrupted streams,delta_streamaccumulation, two calls to the same tool resolving to distinct ids, thread-context propagation, retrieval document mapping, and instrumentation failing without raising into user code.Two things reviewers should know about the test approach:
@register_llm, which exercises the sameBaseChatModel.chatcode path as a real backend and makes the DashScope-only token behaviour testable without credentials. The trade-off is that no test pins the real wire format — in particular DashScope'smodel_service_info. Happy to add cassettes if preferred; the DashScope one needs a key.Retrievaland skips its__init__(which builds aDocParserand a search tool) so the genuineisinstancepath is exercised without the[rag]extras.Verified end to end beyond the unit tests: against a local Qwen via an OpenAI-compatible server, with the resulting span trees read back from both Arize AX and a local Phoenix.
🤖 Generated with Claude Code