Skip to content

feat: add loongsuite-instrumentation-agentuniverse - #275

Draft
RichardoMrMu wants to merge 39 commits into
alibaba:mainfrom
RichardoMrMu:feat/agentuniverse-instrumentation
Draft

RichardoMrMu wants to merge 39 commits into
alibaba:mainfrom
RichardoMrMu:feat/agentuniverse-instrumentation

Conversation

@RichardoMrMu

@RichardoMrMu RichardoMrMu commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Adds independent Agent, LLM and Tool instrumentation for stable agentUniverse
0.0.19.1 APIs
, using the framework's six wrapper extension points and the
shared GenAI handler. Stable native ReAct uses LangChain's real AgentExecutor;
alpha-only round hooks and provider/dispatcher APIs are capability-conditional
and do not prevent the stable core layers from enabling.

Draft: normal installation is blocked by the published framework package.
agentUniverse 0.0.19.1 pins grpcio==1.63.0; the aligned OpenTelemetry gRPC
exporter requires grpcio>=1.63.2. The framework unconditionally depends on
both HTTP and gRPC exporter distributions, so selecting HTTP does not remove
this conflict. An upstream dependency correction and a new compatible stable
package are required before this PR is ready to merge. The version constraint
will need to follow that actual verified release.

Runtime diagnostic tests use a visibly versioned local framework wheel that
changes only dependency metadata. All 576 framework files are byte-identical
to the official stable wheel. These runs establish API behavior; they are
not acceptance of an unmodified published-wheel installation. Neither
--no-deps nor skip_dep_check is an advertised installation path.

The instrumentation preserves framework result objects and exceptions, reads
stable public reasoning/response IDs/finish reasons from retained SDK payloads,
corrects inclusive provider Token totals, captures native ReAct JSON arguments,
keeps Agent output limited to the final answer, and preserves sessions and
stream cleanup. Text ReAct supplies no provider tool-call ID; none is invented.

Type of change

  • New feature
  • This change requires a documentation update

How Has This Been Tested?

  • Plugin pytest suite in the explicitly documented metadata-corrected
    diagnostic environment: 117 passed, six alpha-only API checks skipped.
    The OpenTelemetry SDK/exporters use the repository-pinned core revision.
  • tox -e precommit with a fresh hook cache, including full repository
    Ruff, formatting, uv-lock and rstcheck hooks. No unrelated lockfile changes.
  • tox -e generate-workflows; generated workflows remain current.
  • Real stable ReAct/AgentExecutor, LangChain 0.1.20 and OpenAI 1.55.3 SDK
    parsing: three sessions, each with two user turns and four model decisions
    per turn; actual stock, price and multiplication tools; synchronous calls
    and two concurrent full-stream reasoning conversations.
  • Reasoning enabled/disabled, nonempty public reasoning deltas, usage-only
    tails, real stable-channel raw tool-call JSON and fragmented SSE arguments,
    SPAN_ONLY/NO_CONTENT, fault isolation, a clean sibling, cancellation and
    cross-task/unread stream cleanup.
  • Normal installation through the published framework package. The focused
    tox environment's resolver rejects its exact gRPC pin before tests run.
  • Python 3.10/3.11 runtime execution. Local execution used Python 3.12;
    the declared CI matrix remains blocked on the same installation problem.

Validation Evidence

The representative stable application uses the framework's original executor,
tool conversion/dispatch and actual SDK JSON/SSE decoding. Only the synthetic
provider HTTP boundary is deterministic. Each workload performs 24 model
requests and 18 real tool executions, retaining second-turn history and
isolating concurrent sessions. Separate native-instrumentation and
uninstrumented executions serve as controls.

Compared original PR head 870fff6 with the stable-compatible implementation.
The original accepts the stable version but raises while installing missing
alpha round hooks and leaves wrappers partially installed; this failed
activation is explicitly recorded in the diagnostic baseline. Under that
observed state, the same workload produces 51 spans before and after. Its LLM
usage is corrected from 600 input + 312 output = 912 to provider-reported
480 input + 240 output = 720. Nonempty public reasoning is recorded separately
on all 24 model outputs when enabled. Native tool arguments and final Agent
answers are verified against actual business execution.

Seven bounded before/after/control datasets plus a separate final-commit
healthy-path verification were exported through the actual OTLP exporter and
read back from ARMS: 361 spans, with no differences in source span IDs,
parents, kinds or attributes. Content-disabled runs omit message and tool
argument/result carriers. Cloud validation covers traces; event-log and metric
delivery were not part of this stable-version cloud acceptance.

The span count remains 51 on the healthy representative path. Serialized span
payload is approximately 148 KB before and 150 KB after; corrected metadata
and public reasoning account for the changed content. These are payload
comparisons, not billing estimates. Fixture privacy scanning is clean.

The stable framework also emits an existing StreamOutPutCallbackHandler
AgentFinish warning in uninstrumented/native control runs. Final Agent results
are correct; this PR does not change that framework callback or claim its
application-level stream completion event has been repaired.

Does This PR Require a Core Repo Change?

  • No LoongSuite core source change.
  • An upstream agentUniverse dependency correction/new stable package is
    required before installation and merge acceptance.

Checklist:

  • Followed the style guidelines of this project
  • Changelog and supported-version documentation updated
  • Runtime regressions and failure-path checks added
  • Actual trace export/readback evidence collected
  • Published-wheel installation and supported Python CI matrix pass

@RichardoMrMu
RichardoMrMu force-pushed the feat/agentuniverse-instrumentation branch from 48a3dac to 8e0486f Compare September 28, 2026 07:05
@RichardoMrMu
RichardoMrMu marked this pull request as draft September 28, 2026 07:11
The previous implementation wrapped Agent.run / Agent.async_run directly,
which produced a duplicate span whenever agentUniverse's own
AgentInstrumentor (0.0.18+) was enabled as well -- which is the normal
setup.

Delegate span creation to the native instrumentor and add the LoongSuite
gen_ai.* conventions to the very same span:

- reuse an already-active native AgentInstrumentor, or create and
  instrument one only when none is active (tracked as _native_owned)
- patch AgentSpanAttributesSetter.set_input_attributes,
  set_success_attributes and set_error_attributes so each calls the native
  original first and then writes gen_ai.* on the same span
- the shared content-capture switch is read once, at instrument() time:
  only SPAN_ONLY and SPAN_AND_EVENT write gen_ai.input.messages
- sentinel-guarded, idempotent instrument/uninstrument; uninstrument
  restores the original setters and only tears down a native instrumentor
  that this bridge created

No span is created here and neither run method is wrapped, so Agent.run /
Agent.async_run stay untouched. Native code keeps ownership of the span
name (au.agent.{source}), span kind, span status, metrics, streaming
first-token timing and conversation-memory recording.

The bridge deliberately never constructs AgentInstrumentor() while a
native instrumentor is active: AgentInstrumentor is a BaseInstrumentor
singleton whose __init__ runs on every construction and would reset the
saved wrapper originals, so a later uninstrument() through that fresh
handle would blank the trace-module globals and break every subsequent
Agent.run() with TypeError: 'NoneType' object is not callable.

Tests now run against real agentUniverse 0.0.19.1 with no stand-in,
initializing the ApplicationConfigManager that the native
ConversationMemoryModule requires. They cover the native-only,
LoongSuite-only and both-enabled paths -- asserting exactly one span per
agent call in every case, with both attribute namespaces coexisting on the
same span when both are enabled -- plus content capture on/off, the error
path, async_run, double instrument and uninstrument restore.
…ics proof

Follow-up to the compatibility-bridge refactor, addressing independent audit
findings.

Content privacy: the native AgentSpanAttributesSetter writes the raw prompt to
au.agent.input and the result to au.agent.output unconditionally, so declining
gen_ai.input.messages alone did not actually keep user content off the span.
When this bridge activated the native instrumentor and content capture is off,
the two content-bearing setters are now handed a redacting span view that drops
exactly those two attributes -- au.span.kind, au.agent.name, the caller
attributes, pair_id, duration, status, usage.* and error.* are all still
recorded. A native instrumentor the application activated itself is never
filtered: its au.agent.input/output behaviour stays governed by the
application's own configuration, and that asymmetry is documented in the README
and CHANGELOG.

Session propagation is now explicitly out of scope in the docs: au.trace.session.id
and AUSessionPropagator come from agentUniverse's TelemetryManager.init_from_config(),
the bridge activates only the Agent instrumentor, and a test asserts the global
propagator is unchanged across instrument()/uninstrument().

Metrics, streaming first-token timing and token usage are now asserted directly
rather than assumed: an InMemoryMetricReader checks that every native metric is
recorded exactly once, that agent_calls_total reads 1 on the both-enabled path
(which is what proves the native instrumentor was not instrumented a second
time), that a real queue.Queue output_stream produces the first-token data point
with au_agent_streaming=True, and that au.agent.usage.* keeps its native shape.

Package metadata: wrapt is dropped -- the bridge patches the native static
setters with setattr and never used it, and opentelemetry-instrumentation
already depends on it. requires-python is bounded to >=3.10,<3.13 and the 3.13
classifier is gone, matching the tox matrix, because agentUniverse's pins
(numpy<2, grpcio==1.63.0, pyarrow<17) have no cp313 wheels.

Mutable evidence for the new behaviour: dropping the privacy filter fails 7
tests, neutering the setter bridge fails 18, and making the bridge always create
its own native instrumentor fails 4. 27 tests pass on Python 3.12 against a real
agentUniverse 0.0.19.1.
The initial scope covered only the Agent layer, which left the LLM and tool
spans produced by the same agentUniverse call tree without the LoongSuite
GenAI conventions -- and, more importantly, left the package's coverage
incomplete rather than wrong. agentUniverse ships one native instrumentor per
layer (agentUniverse 0.0.19.1, upstream 254ecd280f54c2d2de654bbe5373e9f947d2dedf):

  * agentuniverse/base/tracing/otel/instrumentation/agent/agent_instrumentor.py
  * agentuniverse/base/tracing/otel/instrumentation/llm/llm_instrumentor.py
  * agentuniverse/base/tracing/otel/instrumentation/tool/tool_instrumentor.py

each of which patches the module-level wrapper globals in
agentuniverse/base/annotation/trace.py that the @trace_agent, @trace_llm and
@trace_tool decorators read at call time. The bridge now treats those three as
interchangeable layers and never creates a span itself.

Implementation. A frozen _LayerSpec describes each layer (native instrumentor
path, setter path, wrapper globals, private content attributes, applier and the
list of bridged setter methods) and a _LayerBridge installs or uninstalls one
layer: it reuses a live native instrumentor found through the wrapper globals,
or constructs and instruments one and marks it owned. Each layer gets its own
applier that writes the LoongSuite attributes on the native span after the
native setter has run:

  * agent -- gen_ai.span.kind=AGENT, gen_ai.operation.name=invoke_agent,
    gen_ai.agent.name; content on gen_ai.input.messages
  * llm -- gen_ai.span.kind=LLM, gen_ai.operation.name=chat,
    gen_ai.request.temperature (the native -1 sentinel is skipped), content on
    gen_ai.input.messages / gen_ai.output.messages, and
    gen_ai.response.time_to_first_token via a bridged
    set_first_token_attributes
  * tool -- gen_ai.span.kind=TOOL, gen_ai.operation.name=execute_tool,
    gen_ai.tool.name, gen_ai.tool.type, gen_ai.tool.call.id from the native
    au.tool.pair_id, content on gen_ai.tool.call.arguments /
    gen_ai.tool.call.result

and all three also mirror their own au.*.usage.* counters into gen_ai.usage.*
(non-zero only) and record error.type from the exception class. Ownership and
the privacy policy are per layer: a bridge-owned layer with capture off is
handed a redacting span view that drops exactly that layer's native content
attributes, while a layer the application activated itself is never modified.

Two latent bugs surfaced while wiring the LLM and tool layers and are fixed
here: _unwrap_params treated every mapping whose values were all mappings as a
kwargs payload, which turned the LLM call's bound {'prompt': ..., 'kwargs': {}}
into a JSON blob for gen_ai.input.messages -- it now unwraps only the single
'kwargs' key the agent entry point produces. And the tool applier read the
pair id from the wrong argument index (args[4] instead of args[3] of
set_input_attributes), so gen_ai.tool.call.id was always missing.

Tests. 52 tests now run against a real agentUniverse 0.0.19.1 on Python 3.12,
with no stand-in, and cover native-only, LoongSuite-only, both-enabled and
partially-owned setups across all three layers, asserting exactly one span per
layer -- a typical call tree is one agent + one LLM + one tool span, never two.
Sync and async paths, agent and LLM streaming first-token (positive duration on
the span and in the histogram), error status and error metrics, the content
capture matrix in both ownership modes, the instrument/uninstrument lifecycle
including setter and wrapper-global restoration, and token usage are all
asserted directly:

  * a real @trace_llm child returning TokenUsage(text_in=3, text_out=5)
    aggregates into au.agent.usage.total_tokens=8, prompt_tokens=3,
    completion_tokens=5 on the parent span, into agent_total_tokens=8,
    agent_prompt_tokens=3, agent_completion_tokens=5 on the parent metrics and
    into gen_ai.usage.total_tokens=8 on the same span (TokenUsage is a
    pydantic-v1 model whose prompt_tokens/completion_tokens/total_tokens are
    derived properties, so TokenUsage(prompt=3, completion=5, total=8) sets
    nothing at all)
  * every native metric is recorded exactly once through an
    InMemoryMetricReader, *_calls_total reads 1 per layer, and the *_tokens
    histograms carry the real non-zero totals
  * the session path is exercised for real in a separate process:
    TelemetryManager.init_from_config() registers SessionSpanProcessor and
    AUSessionPropagator, the agent, LLM and tool spans all carry
    au.trace.session.id, and the propagator injects and extracts both
    AU-SessionId and auSessionId

Mutation checks keep the tests honest: neutering the agent, LLM or tool bridge,
disabling the privacy filter, disabling the gen_ai usage mirror, or removing
the session id from the session probe fails 16 / 16 / 12 / 14 / 4 / 1 tests
respectively, and the suite is green again once the files are restored.

Two agentUniverse 0.0.19.1 behaviours the bridge does not change are pinned by
tests and documented: streamed LLM usage is counted twice into the parent agent
span and metrics (the LLM span keeps the real numbers), and a native
instrumentor's __init__ resets its own saved state, so it must not be
constructed while it is already active. README and CHANGELOG are rewritten for
the three-layer scope, and the README covers the ownership/privacy matrix, the
conditional session story and the requirements range.
…mentation

Replace the compatibility bridge with an independent implementation: the
instrumentor takes over the six module-level wrapper extension points of
agentuniverse.base.annotation.trace and produces the Agent, LLM and Tool
telemetry itself -- spans, the au.* attribute set, all 26 metrics, streaming
first-token timing, conversation memory, the invocation chain, token
aggregation and error handling -- and adds the LoongSuite gen_ai.*
conventions to those same spans through the shared ExtendedTelemetryHandler.

There is no runtime import, delegation or patching of the framework's
instrumentors, span-attribute setters, span managers or metric recorders.
Installing is transactional: the six extension points are snapshotted,
replaced, and rolled back on failure; uninstrument restores each saved
object unless something else replaced it in the meantime.

Two differences from the framework's own instrumentation are intentional and
pinned by baseline tests: content capture is honoured (NO_CONTENT writes no
content carrier on any layer), and streamed LLM usage is aggregated once
instead of twice.

Tests: 74 passed, 1 skipped against a real agentUniverse 0.0.19.1 on Python
3.12, including a native-vs-LoongSuite baseline matrix. Mutation checks for
the three layer wrappers, the takeover, rollback/restore, privacy, stream
finalization, token aggregation and the session probe all turn red as
expected.
…he framework

An errored tool span now also carries the `au.tool.usage.*` attributes, so its
`au.*` contract matches the framework's own instrumentation attribute for
attribute -- the framework records the zero usage of a failed call too. The
span status was already ERROR on both sides.

Pin it with a baseline comparison of the tool error path: the framework's run and
this package's run of the same failing tool are compared on span kind, status,
parent and the `au.*` key set, plus the `tool_errors_total` point. The only
differences left on that path are the documented ones: the error-message text
under the privacy policy, and the framework writing content unconditionally.

Refresh the documented evidence while here: 75 passing tests, and the per-mutation
failure counts.
…n the metric sets

Declare the package's metrics in its packaging metadata (`supports_metrics` in
pyproject.toml, `_supports_metrics` in package.py) so the generated
instrumentation-loongsuite README lists it with metrics support instead of
claiming none. The key stays out of the wheel METADATA and the build is
unaffected.

Strengthen the baseline matrix: the metric families are compared as exact sets
on both runs -- nine agent, nine LLM and eight tool families, none missing and
none extra, which is 23 exported families per successful run plus the shared
handler's two `gen_ai.client.*` families on this package's spans. The tool call
carriers are asserted directly in both capture modes instead of being covered
only by the generic content list.

Refresh the documented evidence: 77 passing tests, and the per-mutation failure
counts.
main added two loongsuite extension packages that land in the same generated
files this branch edits: a2a (alibaba#274) and llama-index (alibaba#273). Those two commits are
the whole diff from the merge base, so the conflicts are the three list files
(tox-loongsuite.ini, pyproject.toml, instrumentation-loongsuite/README.md) and
the two generated workflow files.

Resolved as a union: every entry from both sides is kept, with agentuniverse
placed next to a2a and llama-index. The two workflow files are not hand-edited;
they are the output of the repo's own generator run over the merged
tox-loongsuite.ini (python .github/workflows/generate_workflows_loongsuite.py),
so they match `tox -e generate-workflows` byte for byte.
@RichardoMrMu
RichardoMrMu marked this pull request as ready for review October 12, 2026 06:37
[testenv]deps carries `agentuniverse: {[testenv]test_deps}` and `agentuniverse:
-r .../loongsuite-instrumentation-agentuniverse/test-requirements.txt`. tox
splits an env name on `-` into factors, so the factor `agentuniverse` also
matches `lint-loongsuite-instrumentation-agentuniverse`, and that env installs
the package's test-requirements on top of `-r dev-requirements.txt`.

Measured on bc14430 (runs/38213319242/job/114692312074): the job fails during
install_deps, before ruff runs.

  install_deps> uv pip install ... -r dev-requirements.txt
                -r .../loongsuite-instrumentation-agentuniverse/test-requirements.txt
  x No solution found when resolving dependencies:
    ... And because you require sphinx-rtd-theme==2.0.0rc4 and
    agentuniverse>=0.0.19, we can conclude that your requirements are unsatisfiable.

dev-requirements.txt pins sphinx-rtd-theme==2.0.0rc4 while agentUniverse>=0.0.19
needs sphinx-rtd-theme>=2.0.0,<3.0.0, and the package declares requires-python
<3.13 while this env runs 3.13.

Give the env its own deps: the list the lint factor produces for every other
package, without the framework. Verified with tox 4.65.3 that `tox -c
tox-loongsuite.ini c -e lint-loongsuite-instrumentation-agentuniverse` no longer
resolves test-requirements.txt, and that the six generated workflow files are
byte-identical before and after this change.

Alternative considered: raising dev-requirements.txt to sphinx-rtd-theme>=2.0.0.
Not taken -- it changes every lint env in the repo for one package's dependency.
@RichardoMrMu

Copy link
Copy Markdown
Collaborator Author

Root cause of the red LoongSuite Test 0 jobs (py3.10, py3.11, py3.12 and the aggregate gate): the test env's dependencies cannot be resolved, and it is not the tox factor wiring.

For this env, [testenv]deps resolves to the same two layers every other package here uses: {[testenv]test_deps} (the core pinned by CORE_REPO_SHA) plus instrumentation-loongsuite/loongsuite-instrumentation-agentuniverse/test-requirements.txt. Both lines are scoped by the agentuniverse factor and nothing else matches. The failure is at install_deps, before pytest runs.

The contradiction is inside agentUniverse's own metadata:

  • agentUniverse==0.0.19 and ==0.0.19.1 both declare grpcio==1.63.0 and opentelemetry-exporter-otlp-proto-grpc>=1.25.0.
  • Every exporter release that accepts grpcio 1.63.0 (1.25.0, 1.26.0, 1.27.0; grpcio<2.0.0,>=1.0.0) pins the SDK to its own window, e.g. 1.27.0 needs opentelemetry-sdk>=1.27.0,<1.28.dev0.
  • Every exporter release from 1.28.0 up needs grpcio<2.0.0,>=1.63.2, and from 1.31.0 on the requirement is split by Python: grpcio>=1.63.2; python_version < "3.13". That contradicts grpcio==1.63.0.
  • This package declares opentelemetry-api>=1.37.0 and opentelemetry-instrumentation>=0.58b0, so the env has to carry the current core.

Measured with uv 0.11.26 on py3.12, resolving each set in isolation:

requirement set result
agentUniverse==0.0.19.1 resolves, 190 packages, grpcio 1.63.0, SDK in the 1.25-1.27 window
agentUniverse==0.0.19.1 + opentelemetry-api>=1.37.0 + opentelemetry-sdk>=1.37.0 unsat: agentuniverse==0.0.19.1 depends on opentelemetry-sdk>=1.25.0,<1.28.dev0 ... you require opentelemetry-sdk>=1.37.0
the same, plus grpcio==1.63.2 unsat: agentuniverse==0.0.19.1 depends on grpcio==1.63.0
the same, plus opentelemetry-exporter-otlp-proto-grpc==1.27.0 unsat: 1.27.0 needs opentelemetry-sdk<1.28.dev0
the same, plus --constraint "opentelemetry-exporter-otlp-proto-grpc<1.28" unsat, same as the row above
agentUniverse==0.1.0a2 + the same core resolves, 170 packages, grpcio 1.85.0rc1

The CI run reaches the same conclusion in its own words: agentuniverse>=0.0.19 depends on grpcio==1.63.0 ... we can conclude that agentuniverse>=0.0.19 cannot be used. Note that even the first in-range exporter version, 1.25.0, is not a way out: it needs opentelemetry-sdk>=1.25.0,<1.26.dev0.

So no constraint layering, no Python marker and no pinned version inside agentUniverse's declared range makes this env installable. The two tweaks that would make it green both change what is tested: --no-deps or a forced grpcio stops installing agentUniverse's declared dependencies, and the pre-release tests an alpha instead of the stable line. I am not making either call unilaterally.

Kept in the PR, unchanged by the above: the matrix stops at 3.12 and requires-python is >=3.10,<3.13, both for the numpy<2 / pyarrow<17 wheel reason already documented in tox-loongsuite.ini and pyproject.toml. That bound is independent of this failure: the contradiction holds on 3.10, 3.11 and 3.12 alike.

The env becomes installable again when agentUniverse ships a release that drops grpcio==1.63.0, or if the project decides to run this env against agentUniverse==0.1.0a2 with --prerelease=allow.

@sipercai sipercai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agent / LLM / Tool 三层接入有价值,wrapper 的接管、回滚和恢复也考虑得比较完整。

目前 GenAI util 主要参与属性写入与结束处理,Span 创建、Context、流生命周期和指标仍由插件自行维护。建议优先复用 util 能承接的生命周期及共享 advice,减少重复实现,便于后续统一维护。

在 agentUniverse 0.0.19.1 + 真实模型调用中复现了下面几个问题,建议先修复并补充回归测试。模型/provider 映射、ReAct STEP 和 Agent 最终输出边界也需要进一步对齐。

RichardoMrMu and others added 2 commits October 13, 2026 15:40
Review follow-ups on the LLM layer; every behaviour here has a test that
fails on the previous code.

_llm.py: process_async_stream is a plain function again, returning a
ClaimedAsyncStream. As an async generator its body -- and with it
mark_streaming(), which hands the caller's context token over -- only ran
on the caller's first __anext__. A stream that was awaited and then closed
without being read therefore kept the caller inside the LLM span and left
that span recording, and a caller resuming after the await started its
next span as a child of a span that never ended. The wrapper claims the
stream when it is created and finalizes the span on an aclose() that never
read a chunk, which is what the sync twin's early close already did.

_llm.py: usage_of() reads the usage the framework left in LLMOutput.raw
through TokenUsage.from_openai/from_anthropic when LLMOutput.usage is
empty, so au.*.usage.* and the shared handler's gen_ai.usage.* are
populated for a provider whose usage payload does not reach the field the
framework reads. Streamed chunks go through the same reader.

_llm.py: chunk_text() keeps the empty text of a role-only or usage-only
chunk empty instead of falling back to str(chunk); the async loop used to
write a whole LLMOutput repr into gen_ai.output.messages.

_common.py: LayerSpan.attach detaches its context token and ends its span
when the token bookkeeping that follows the attach raises, so a failed
attach cannot leave the trace filing later spans under a span that never
ends.

Tests: tests/test_llm_review_followups.py, 9 cases. 5 fail before this
commit and pass after it; the rest are guards that pass on both.
The review body asked for the LLM layer's span/context lifecycle to be the
shared handler's responsibility, and for the GenAI identity to match the other
plugins. Two behaviour changes, both measured on the current head before the
fix:

  teardown        abandon_llm was never called: finalize detached the leftover
                  context token itself and ended the span itself
  request.model   gen_ai.request.model carried the component name (stub_llm)
                  rather than the model the call asks for

finalize now hands the invocation to the handler's own best-effort cleanup
(abandon_llm) after stop_llm/fail_llm, so the plugin no longer detaches a token
or ends an LLM span. The token hand-over before finalizing stays, because that
is what stop_llm detaches. gen_ai.request.model reads LLM.model_name and falls
back to the component name, which au.llm.name keeps reporting either way;
gen_ai.provider.name stays the channel name, the only provider identity the
framework exposes (LLMChannel has channel_model_name and no provider field).

Span creation and the context attach stay in the plugin: start_llm creates the
span and names it "{operation_name} {request_model}", so delegating it would
rename the exported au.llm.<name> spans.

tests/test_llm_lifecycle_alignment.py pins all of it, including start_span
raising before attach (the call stays uninstrumented and the next call of the
trace is a root span) and the component-name fallback.

Verified: the two modules 15 passed; package suite 1 failed / 92 passed, the
single failure being the pre-existing
test_intentional_difference_streaming_token_count baseline drift on
agentUniverse 0.1.0a2; ruff format --check and ruff check clean.
@RichardoMrMu

Copy link
Copy Markdown
Collaborator Author

Re-verified against the review body's three points, on 6c8bb2d (adds tests/test_llm_lifecycle_alignment.py).

(a) Lifecycle reuse - delegated where the handler owns it, not where it does not.

finalize now hands the invocation to the handler's own cleanup instead of doing the last step itself. abandon_llm detaches a still-pending token and ends a still-recording span, and it is a no-op after stop_llm/fail_llm, so the plugin no longer detaches a context token or ends an LLM span on its own (_llm.py::_release_context_token is gone). The token hand-over before finalizing stays: that is the token stop_llm detaches.

What stays in the plugin, and why:

  • Span creation and the context attach. ExtendedTelemetryHandler.start_llm creates the span and names it f"{operation_name} {request_model}"; this package's exported span name is au.llm.<component name>, which the surrounding contract (au.llm.name on every record, and the framework's own naming) depends on. Handing span creation to start_llm would rename every span, so the plugin keeps LayerSpan and the otel_context.attach plus rollback that goes with it.
  • The au.* metric families. The handler records gen_ai.* metrics; the nine llm_* metrics re-create what the framework's own instrumentation publishes, and their labels are au.llm.name. They are not a duplicate of the handler's series.

(b) Semantic alignment.

  • gen_ai.request.model now reads LLM.model_name (the provider model, e.g. gpt-4) and falls back to the component name when none is configured; au.llm.name reports the component name either way. Before this commit it always carried the component name.
  • gen_ai.provider.name stays the channel name. It is the only provider identity the framework exposes: LLMChannel has channel_name/channel_model_name and no provider field, and the provider classes (openai/claude/gemini/...) are channel implementations rather than a value a call carries.
  • ReAct STEP modelling and the agent's final-output boundary: the agent layer already writes the agent's final output as AgentAttributes.OUTPUT and as the invocation's output_messages (_agent.py), so the boundary matches the merged plugins. I have not added a multi-step ReAct test, because the framework's step loop needs a full agent run to produce one - nothing here asserts a different boundary.

(c) tests/test_llm_lifecycle_alignment.py::TestSpanCreationFailure covers tracer.start_span raising before anything is attached: the business call still answers, no span is recorded, and the next call of the same trace is a root span. Both tests pass on the current code, i.e. the guard already covers it; they pin the behaviour rather than changing it.

Verification on 6c8bb2d: the two LLM test modules 15 passed; the package suite 1 failed / 92 passed, the single failure being the pre-existing test_intentional_difference_streaming_token_count baseline drift on agentUniverse 0.1.0a2, which I am not papering over; ruff format --check and ruff check clean on the changed files. The grpcio/dependency matrix is unchanged.

@RichardoMrMu

Copy link
Copy Markdown
Collaborator Author

Unrelated red light in this run's CI: every loongsuite-instrumentation-strands-latest job (py3.10-3.13, e.g. job 115185430508 on 3.13) fails because strands-agents 1.60.0 attaches an invocation baggage context inside Agent.stream_async and detaches it in the generator's finally; closing the stream from another context (asyncio.create_task(stream.aclose()), which copies the context) makes opentelemetry.context.detach log Failed to detach context and raise ValueError: ... was created in a different Context. The same jobs are green with 1.59.0 (run 38215548191), and strands-oldest (1.50.2) is green in both runs, so it is not related to this PR's changes. Root cause, the 1.60.0 RED / 1.59.0 + 1.50.2 GREEN evidence and the compatibility fix are in #278.

@sipercai sipercai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

异步流返回前 detach、未消费即 aclose、attach 后初始化失败清理已通过复验;LLM 的 abandon_llm 清理委托也有改进。

版本范围需要同时考虑当前稳定版与预发布版:PyPI 默认稳定版仍为 agentUniverse 0.0.19.1,0.1.0a2 是新 API 的 alpha。稳定版复验中,真实同步/异步流被新增 usage fallback 的 AttributeError 中断;在 0.1.0a2 上,真实流式业务和 Token 正常,但 LLMStreamEvent 的输出内容没有采集。下面分别给出兼容、fail-open 和新版流式映射的行内意见。

依赖与测试矩阵也需要对齐实际支持范围。本插件的 Python 3.10/3.11/3.12 CI 在 install_deps 阶段因框架与 OTel 依赖矛盾失败,不能仅以其他包的无关失败解释。本次稳定版运行使用受控依赖环境隔离运行行为,默认完整依赖解析另行检查,仍为 unsatisfiable;应解决正式安装路径,而不是依靠 --no-deps 作为安装验收。新版测试也需按原生行为更新,不能继续假设 native streamed usage 翻倍。

0.1.0a2 的原生工具循环已跑通两轮,每轮 3 次模型调用、2 次工具,最终返回 60/30;仍缺 STEP,工具规划 LLM 的 output_messages 缺失,Agent output 包含整份输入与框架元数据。逐调用 model 覆盖也仍未映射:实际 call(model='qwen-plus') 时 Span 记录的仍是实例配置 qwen-turbo。建议一并补齐真实 adapter、原生工具循环与内容边界测试,并继续收敛共享 advice 和 util 生命周期的责任边界。

payload = raw.get("usage") if isinstance(raw, dict) else None
if not isinstance(payload, dict):
return None
for convert in (TokenUsage.from_openai, TokenUsage.from_anthropic):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1] 兼容当前稳定版的 usage 转换 API

这里在构造 tuple 时就访问 TokenUsage.from_anthropic,而当前稳定版 agentUniverse 0.0.19.1 没有这个方法。即使 raw payload 是 OpenAI usage,也会在第一次转换前抛 AttributeError。真实同步/异步流因此中断,非流式虽保留业务结果,但结束映射提前失败,输出和 Token 仍缺失。请避免无条件访问可选转换 API,分别覆盖稳定版与 0.1.0a2 的真实 adapter/stream 返回结构。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

按能力取转换器了:raw_usage_converter() 逐个 getattr 再检查 callable,0.0.19.1 没有 from_anthropic 也能正常转换。我拿 stable 的 wheel(0.0.19.1)里的 TokenUsage 直接跑 usage_of,返回的 usage 正确;测试里也补了对应用例(缺 from_anthropic、以及两个转换器都没有这两种情况)。

first = False
run_safely(self._finish_first_token)
output.append(chunk_text(chunk))
chunk_usage = usage_of(chunk)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1] 隔离逐 chunk 的采集异常

chunk_text / usage_of 的采集处理直接位于业务迭代内,发生异常会走下面的 re-raise。在稳定版和 0.1.0a2 上,实测将 usage_of 注入为采集故障后,消费者收到同一 probe 异常,原本应交付的 3 个 chunk 一个也没收到;下一次干净调用正常。请用共享 advice 隔离纯采集步骤,即使丢弃遥测也继续按原顺序交付同一 chunk 对象;provider 迭代本身的业务异常仍保持原对象透传。同步循环也需覆盖相同回归。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

逐 chunk 的读取(文本、usage、tool call 累加)都收进 run_safely 了,采集异常只留在遥测层。新增「坏 chunk」测试:3 个 chunk 原对象、原顺序交付给调用方,span 依然正常结束。

without a ``text`` field is not a framework payload and is rendered as it
is.
"""
text = getattr(chunk, "text", None)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] 采集新版 LLMStreamEvent 的 text_delta

agentUniverse 0.1.0a2 的真实流返回 LLMStreamEvent,文本位于 text_delta,text 是构造 classmethod。这里将其视为“存在但非字符串”并返回空串,因此业务完整收到答复、Token 正确,但同步/异步 LLM Span 的 gen_ai.output.messages 均缺失。请按实际事件类型采集文本增量、工具调用与终态,并用真实 adapter 的流式输出补回归测试。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

chunk_record() 现在先读 text、再读 text_delta。用真实的 LLMStreamEvent.text("part-1") 实测:修复前 span 输出是空串,修复后是 part-1part-2。

Nine review findings, each with a red-then-green check:

- read streamed text from LLMStreamEvent.text_delta as well as from text;
- resolve the raw-usage converter by capability, so a release without
  TokenUsage.from_anthropic (0.0.19.1) still converts;
- keep a per-chunk collector failure inside the telemetry layer, so the
  chunks reach the caller unchanged and in order;
- report a tool-planning round's tool calls in gen_ai.output.messages;
- publish the agent's answer instead of the framework's call envelope;
- map a per-call model= override onto gen_ai.request.model;
- record one ReAct STEP span per tool-calling round, with the round's usage
  promoted onto the agent span and an open step closed when the round aborts;
- follow the installed release's streamed-usage accounting in the matrix.

Tests: pytest tests -q -> 108 passed; ruff check and ruff format --check clean.
Red baseline with the sources reverted and the new tests kept: 12 failed.

Requires-Python: >=3.10,<3.13.
@RichardoMrMu

Copy link
Copy Markdown
Collaborator Author

总评这 6 点都处理了,命令与结果放在最后。

  • ① 依赖冲突:用 uv pip compile(3.10 与 3.12)定位到是 agentUniverse 自己的元数据互斥 —— agentuniverse>=0.0.19 钉 grpcio==1.63.0 并要求 semconv>=0.48b0,而它要求的 opentelemetry-exporter-otlp-proto-grpc>=1.25.0 被压到 <1.28.0 之后依赖的 semconv 区间与 >=0.48b0 没有交集;resolver 只接受 agentuniverse<=0.0.19 / ==0.0.19.1,并提示预发布 0.1.0a2 可解。这不是本包能消掉的:--no-deps 不算安装成功,我也不想用 pin 把冲突盖掉。想请你选一个方向,我按你选的改:(a) 这个包的 3.10–3.12 测试环境允许预发布,agentuniverse 指到 0.1.0a2;(b) 该包暂时只保留可解的组合,并在 README 写明原因;(c) 放宽该环境对 exporter/semconv 的下限,或等上游发一个放宽 grpcio/semconv 的 stable 版本。
  • ② streamed token 翻倍:实测清楚了 —— 0.0.19.1 的 stream 循环和 _finalize_streaming_result 都会 add_current_token_usage,父 span 拿到 20;0.1.0a2 在 _finalize_streaming_result 去掉第二次调用(源码注释写明避免双计),是 10。断言改成按安装版本分支,不再假设翻倍。
  • ③ STEP:新增 _step.py,接 Agent.on_before_llm_round / on_after_llm_round 这两个框架自己的 hook(工具循环每轮必调,async 版默认委托同步),每轮一个 react step span:gen_ai.span.kind=STEP、gen_ai.operation.name=react、gen_ai.react.round 从 1 起、gen_ai.react.finish_reason;round 里累积的 token usage 汇总到 agent span,轮次中途抛异常时未关闭的 step 也会在 finalize 里收掉。async 路径和真实 run_tool_calling_loop 都有测试。
  • ④ 工具规划轮:那一轮的 tool calls 现在会写进 gen_ai.output.messages(用框架自己的 StreamReducer 聚合出的 tool call,实测包含工具名和解析后的参数)。
  • ⑤ agent output:au.agent.output 只写最终答案,不再把 {**agent_input, "output": ...} 整个信封(含输入和框架元数据)塞进去。
  • ⑥ model 覆盖:call(model=...) 优先于组件配置,gen_ai.request.model 记的是覆盖后的模型(qwen-plus,不是 qwen-turbo)。

命令与结果:pytest tests -q → 108 passed;ruff check / ruff format --check 干净。RED 基线(只还原 src、保留新测试)→ 12 failed,其中 ② 那条就是 assert 10 == 20。

RichardoMrMu and others added 12 commits October 27, 2026 03:08
…vs core proto-grpc

agentuniverse 0.0.19.1 pins grpcio==1.63.0 exactly, while the pinned
opentelemetry-exporter-otlp-proto-grpc (core SHA 37dea4b) requires
grpcio>=1.63.2 on Python <3.13, making uv dependency resolution fail
before tests run. Add a uv override file pinning grpcio==1.63.0 (the
agentuniverse-supported patch release) and wire it via UV_OVERRIDE only
for the agentuniverse tox envs. ruff check passes.
0.0.19.1 pins grpcio==1.63.0 exactly, conflicting with the pinned
core otlp-proto-grpc dev0 which requires >=1.63.2. The 0.1.0a2
prerelease drops the exact grpcio pin entirely and requires
opentelemetry-api/sdk >=1.30.0 (satisfied by core HEAD). All
conftest import paths (Agent, Tool, AgentModel, trace_llm,
AppConfiger, native instrumentors, LLMOutput) exist in both
versions. Remove the UV_OVERRIDE shim and overrides.txt.
….0.19.1 vs core proto-grpc"

This reverts commit 06f3e2d.
agentUniverse 0.0.19.1 hard-pins grpcio==1.63.0, conflicting with the
pinned core otlp-proto-grpc dev0 which requires grpcio>=1.63.2.
0.1.0a2 drops the exact grpcio pin and requires opentelemetry-api/sdk
>=1.30.0 (satisfied by core HEAD). The instrumentation remains compatible
with both lines; this selects the installable prerelease for the formal
install path rather than --no-deps or a forced grpcio override.
…ool_call message

The 0.1.0a2 ToolCallingLoopAgent.run_tool_calling_loop detects tool calls via
LLMOutput.message.has_tool_calls(), not the raw payload. The ToolPlanningLLM
stub only set raw, so the loop returned after round 1 and the ReAct step/usage
tests recorded one round instead of two. Return an assistant Message carrying
a real ToolCall (built only where the SDK exports the ToolCall/Message surface),
and register the real stub_tool so the native execute_tool_call dispatcher runs
a successful tool rather than a not-found error.

Verified exact same input on agentUniverse 0.0.19.1 (116 passed/7 skipped,
unchanged) and 0.1.0a2 (the two TestReactSteps loop tests now pass; remaining
9 failures are pre-existing stable-protocol fixtures tracked separately).
…DK-version aware

On alpha 0.1.0a2 Agent ships native on_before/on_after_llm_round and the LLM
channel yields LLMStreamEvent (typed usage, no raw dict). Keep the strict
install/uninstall and usage-bearing-chunk assertions but read the
SDK-appropriate surface instead of assuming the stable 0.0.19.1 layout.

Verified: old 0.0.19.1 116 passed/7 skipped unchanged; alpha 0.1.0a2
7 failed/115 passed (was 9 failed/113 passed).
Fix ruff I001 (unsorted-imports) flagged by precommit on the round-hook /
streaming-usage test edit.
…n-claimed

Detect when Agent.on_before/on_after_llm_round has been replaced by a
non-framework owner (qualname != Agent.*) and raise before wrapping, so the
install rolls back atomically instead of overwriting a foreign hook.
@CLAassistant

CLAassistant commented Oct 27, 2026 •

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
2 out of 3 committers have signed the CLA.

✅ sipercai
✅ RichardoMrMu
❌ Richardomu
You have signed the CLA already but the status is still pending? Let us recheck it.

Richardomu and others added 11 commits October 27, 2026 15:39
A prior on_before/on_after_llm_round owner (plain passthrough, functools.wraps
or name metadata) is a legitimate composition point, not a conflict. Remove the
qualname gate that refused it. Replace the B2 atomic test with a real partial
install-failure seam: force the second hook assignment to raise and assert the
whole install rolls back, restoring core wrappers and native hooks.
Replace the invalid staticmethod metaclass patch (which raised TypeError on
the first assignment) with a bound metaclass __setattr__ that raises exactly
once on the second hook assignment, lets restore through, and asserts the
before->after-fail->restore event sequence plus 6 core wrappers and native
hooks identity restoration.
Snapshot hooks after any synthetic monkeypatch so the test does not crash on
stable 0.0.19.1 (which ships no round hooks); on stable the synthetic seam
lambdas are restored by rollback and dropped by monkeypatch teardown.
…c OPENAI_API_KEY

- runtime_app.py: InventoryAgent (SDK 0.0.19.1) has no max_iterations field;
  setting it raises pydantic ValueError. Remove the assignment.
- test_llm_review_followups: monkeypatch OPENAI_API_KEY so the SDK's default
  OpenAI client construction path does not raise OpenAIError in CI.
The stable 0.0.19.1 Agent.invoke_llm resolves the LLM by name through
LLMManager; a process_llm override is never consulted. This left llm=None
in judge_stream, failing every native ReAct conversation test with
AttributeError: 'NoneType' object has no attribute '_channel_instance'.

Register the channel-backed ChannelLLM instance so the framework finds it
by name, and unregister after the workload to avoid leaking into other
tests. The MockTransport clients hold no real sockets.
… on alpha

- test_usage_of_reads: pre-populate OpenAIStyleLLM._client_pool[api_base]
  on agentUniverse 0.1.0a2, where _get_client() bypasses self.client.
- test_stable_native_react_conversations (4 params) and
  test_concurrent_collector_fault: skip on alpha >=0.1.0 because the
  stable text ReAct protocol (messages[-1] text Action format) differs
  from alpha structured tool_calls (empty messages on first round).
- Verified 118 passed, 5 skipped, 0 failed on agentUniverse 0.1.0a2 /
  Python 3.12.14 / pytest 9.1.1.
…sertions

Remove the pytest.mark.skipif that skipped stable ReAct tests on alpha.
Instead, make the runtime contract and lifecycle tests detect alpha 0.1.x
vs stable 0.0.x at import time and assert protocol-appropriate values:

- alpha structured tool_calls: 3 LLM rounds/turn (18 calls total),
  parent span kind AGENT or STEP, reasoning_effort high|none,
  no prior-conversation retention in messages (chat_history carries it).
- stable text ReAct: 4 rounds/turn (24 calls), AGENT parent,
  reasoning_effort high, prior conversation retained in messages.

runtime_app.py: on alpha use StructuredProvider with profile instruction
template and skip LLMManager channel close (shared channel).
runtime_provider.py: validate_body is now an instance method that skips
the prior-conversation check on alpha and accepts reasoning_effort high|none.

All 123 tests pass on agentUniverse 0.1.0a2 (Python 3.12, mock transport).
Move importlib.metadata import to stdlib group at top of files.
Remove unused _stable_react skip decorator definition.
Fixes CI failures: ruff check I001 (4 errors) and precommit ruff-format
(4 files reformatted).
Auto-applied ruff check --fix (3 I001 import errors) and ruff format
(4 files). Verified locally: ruff check clean, ruff format --check clean,
123 tests pass.

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

None yet

Development

Successfully merging this pull request may close these issues.

7 participants