Skip to content

feat(qwen_agent): add OpenInference instrumentation for Qwen-Agent - #3742

Open
jimbobbennett wants to merge 3 commits into
mainfrom
feat/qwen-agent-instrumentation
Open

jimbobbennett wants to merge 3 commits into
mainfrom
feat/qwen-agent-instrumentation

Conversation

@jimbobbennett

Copy link
Copy Markdown
Contributor

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

Wrapped method Span kind Notes
Agent.run AGENT One span per invocation, named <agent name>.run. Agent.run_nonstream is deliberately not wrapped — it calls run internally, so wrapping both would double every agent span.
Agent.run on a Memory CHAIN Memory is an Agent subclass used for file management and RAG rather than reasoning, and Assistant._run runs it on every turn.
BaseChatModel.chat LLM Every backend routes through this one method, so DashScope, the OpenAI-compatible backends, Azure, transformers and OpenVINO are covered with a single span and no duplication.
Agent._call_tool TOOL FnCallAgent is the only subclass that overrides it and it delegates to super(), so wrapping the base method catches every tool call exactly once. The tool_call.id is recovered from the message history, which is the only place Qwen-Agent exposes it.
Agent._call_tool on retrieval RETRIEVER Each retrieved chunk becomes a document.

Multi-agent needs no configuration: Router and GroupChat call run on 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 onto AGENT spans, 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} and Agent._call_llm always 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 adding OpenAIInstrumentor purely for token counts, since platforms that estimate llm.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.py submits to a ThreadPoolExecutor without copying the caller's context, so the spans of agents fanned out by ParallelDocQA become orphaned roots in a separate trace. The instrumentor swaps in a context-preserving executor — the same approach openinference-instrumentation-smolagents uses for local_python_executor — and restores it on uninstrument.

Streaming. Agent.run and chat(stream=True) both return generators that yield the cumulative message list. The chat stream is wrapped in a wrapt.ObjectProxy per the repo's streaming convention; send/throw/close are 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_tool returns early for an unregistered tool without reaching Agent._call_tool, so that case produces no TOOL span.
  • Agent._call_tool swallows tool exceptions and returns the error text as the result, so a failing tool yields a successful TOOL span whose output is the error message.
  • Retrieved documents carry no document.score: Qwen-Agent computes relevance scores in BaseSearch.sort_by_scores but discards them in get_topk.
  • llm.provider is set only for model_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 as qwen_agent.llm.model_type.
  • qwen-agent==0.0.34 imports numpy, soundfile, tqdm and python-dateutil at module load without declaring them, so test-requirements.txt pins them explicitly.

Testing

58 tests, passing on py310-ci-qwen_agent and py314-ci-qwen_agent, with ruff and mypy --strict clean. Covers the required categories (suppress tracing, context attribute propagation, TraceConfig masking) plus span hierarchy and single-trace assertions, run_nonstream not double-counting, abandoned and interrupted streams, delta_stream accumulation, 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:

  • No VCR cassettes. Tests drive a scripted backend registered through Qwen-Agent's own @register_llm, which exercises the same BaseChatModel.chat code 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's model_service_info. Happy to add cassettes if preferred; the DashScope one needs a key.
  • The retrieval test subclasses the real Retrieval and skips its __init__ (which builds a DocParser and a search tool) so the genuine isinstance path 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

Comment on lines +1 to +6
import gc
import json
from importlib.metadata import entry_points
from typing import Any, Dict, List, Optional, Sequence

import pytest

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.

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 the vcr_config fixture 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.

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.

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.

Comment on lines +469 to +477
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

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.

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.AGENT

Similarly for _ToolCallWrapper, prefer the tool class name over the runtime string tool_name.

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.

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.

jimbobbennett and others added 3 commits September 16, 2026 10:23
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>
@jimbobbennett
jimbobbennett force-pushed the feat/qwen-agent-instrumentation branch from e81d3bf to 1e99a4b Compare September 16, 2026 17:23
@github-actions

Copy link
Copy Markdown
Contributor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant