Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

- Add ReAct step span instrumentation for ReAct agents
([#140](https://github.com/alibaba/loongsuite-python-agent/pull/140))
- Each ReAct iteration is wrapped in a `react step` span with `gen_ai.react.round` and `gen_ai.react.finish_reason` attributes
- Uses AgentScope's instance-level hook system for robust, non-invasive instrumentation

## Version 0.1.0 (2026-02-28)

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=NO_CONTENT

- **Models**: ChatModelBase and all subclasses
- **Agents**: AgentBase and all subclasses
- **ReAct Steps**: Per-iteration `react step` spans for ReActAgent (via instance hooks)
- **Tools**: Toolkit.call_tool_function
- **Formatters**: TruncatedFormatterBase.format

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@

import logging
import timeit
from dataclasses import dataclass, field
from functools import wraps
from typing import Any, AsyncGenerator

from opentelemetry.context import get_current as _get_current_context
from opentelemetry.util.genai._extended_common.common_types import (
ReactStepInvocation,
)
from opentelemetry.util.genai.extended_handler import ExtendedTelemetryHandler
from opentelemetry.util.genai.types import Error, LLMInvocation

Expand All @@ -34,6 +39,131 @@

logger = logging.getLogger(__name__)

_REACT_STEP_HOOK_NAME = "otel_react_step"


def _is_react_agent(agent_instance: Any) -> bool:
"""Check if an agent instance is a ReAct agent by duck-typing."""
return hasattr(agent_instance, "_instance_pre_reasoning_hooks")


@dataclass
class _ReactStepState:
"""Per-agent-call state for React step span lifecycle."""

react_round: int = 0
active_step: ReactStepInvocation | None = None
original_context: Any = field(default=None)
pending_acting_count: int = 0


def _make_pre_reasoning_hook(
handler: ExtendedTelemetryHandler,
) -> Any:
"""Create a pre_reasoning hook that opens a new React step span.

Also closes any leftover step from a previous iteration as a fallback
(normal path closes via post_acting).
"""

def hook(agent_self: Any, kwargs: dict) -> None:
state: _ReactStepState | None = getattr(
agent_self, "_react_step_state", None
)
if state is None:
return None

if state.active_step:
state.active_step.finish_reason = "tool_calls"
handler.stop_react_step(state.active_step)
state.active_step = None

state.react_round += 1
inv = ReactStepInvocation(round=state.react_round)
handler.start_react_step(inv, context=state.original_context)
state.active_step = inv
state.pending_acting_count = 0
return None

return hook


def _make_post_reasoning_hook(
handler: ExtendedTelemetryHandler,
) -> Any:
"""Create a post_reasoning hook that counts tool_use blocks
to initialize the pending_acting_count for the current step."""

def hook(agent_self: Any, kwargs: dict, output: Any) -> None:
state: _ReactStepState | None = getattr(
agent_self, "_react_step_state", None
)
if state is None or output is None:
return None

tool_blocks = (
output.get_content_blocks("tool_use")
if hasattr(output, "get_content_blocks")
else []
)
state.pending_acting_count = len(tool_blocks)
return None

return hook


def _make_post_acting_hook(
handler: ExtendedTelemetryHandler,
) -> Any:
"""Create a post_acting hook that decrements pending_acting_count
and closes the step span when all acting calls are done."""

def hook(agent_self: Any, kwargs: dict, output: Any) -> None:
state: _ReactStepState | None = getattr(
agent_self, "_react_step_state", None
)
if state is None or state.active_step is None:
return None

state.pending_acting_count -= 1
if state.pending_acting_count <= 0:
state.active_step.finish_reason = "tool_calls"
handler.stop_react_step(state.active_step)
state.active_step = None
return None

return hook


def _register_react_hooks(
agent: Any, handler: ExtendedTelemetryHandler
) -> None:
"""Register React step tracking hooks on an agent instance."""
agent.register_instance_hook(
"pre_reasoning",
_REACT_STEP_HOOK_NAME,
_make_pre_reasoning_hook(handler),
)
agent.register_instance_hook(
"post_reasoning",
_REACT_STEP_HOOK_NAME,
_make_post_reasoning_hook(handler),
)
agent.register_instance_hook(
"post_acting",
_REACT_STEP_HOOK_NAME,
_make_post_acting_hook(handler),
)


def _remove_react_hooks(agent: Any) -> None:
"""Remove React step tracking hooks from an agent instance."""
for hook_type in ("pre_reasoning", "post_reasoning", "post_acting"):
try:
agent.remove_instance_hook(hook_type, _REACT_STEP_HOOK_NAME)
except (ValueError, KeyError):
pass


class AgentScopeChatModelWrapper:
"""Wrapper for ChatModelBase that hijacks __init__ to replace __call__."""
Expand Down Expand Up @@ -228,11 +358,25 @@ async def async_wrapped_call(
function_name = f"{call_self.__class__.__name__}.__call__"
invocation.attributes["rpc"] = function_name

is_react = _is_react_agent(call_self)
state: _ReactStepState | None = None
if is_react:
state = _ReactStepState(
original_context=_get_current_context(),
)
call_self._react_step_state = state
_register_react_hooks(call_self, self._handler)

try:
result = await original_call(
call_self, *call_args, **call_kwargs
)

if is_react and state and state.active_step:
state.active_step.finish_reason = "stop"
self._handler.stop_react_step(state.active_step)
state.active_step = None

invocation.output_messages = (
convert_agent_response_to_output_messages(result)
)
Expand All @@ -244,11 +388,24 @@ async def async_wrapped_call(
return result

except Exception as e:
if is_react and state and state.active_step:
self._handler.fail_react_step(
state.active_step,
Error(message=str(e), type=type(e)),
)
state.active_step = None

self._handler.fail_invoke_agent(
invocation, Error(message=str(e), type=type(e))
)
raise

finally:
if is_react:
_remove_react_hooks(call_self)
if hasattr(call_self, "_react_step_state"):
del call_self._react_step_state

except Exception as e:
logger.exception("Error in agent instrumentation: %s", e)
return await original_call(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
interactions:
- request:
body:
input:
messages:
- content: Test agent
role: system
- content: Trigger error
role: user
model: qwen-max
parameters:
incremental_output: true
result_format: message
stream: true
headers:
Accept:
- text/event-stream
Content-Type:
- application/json
X-Accel-Buffering:
- 'no'
X-DashScope-SSE:
- enable
authorization:
- Bearer test_api_key
user-agent:
- dashscope/1.25.13; python/3.14.0; platform/macOS-15.1.1-arm64-arm-64bit-Mach-O;
processor/arm; incremental_to_full/0
method: POST
uri: https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation
response:
body:
string: |-
{
"code": "InvalidApiKey",
"message": "Invalid API-key provided.",
"request_id": "575fd494-d816-48b6-92b3-8859550afb34"
}
headers:
Content-Type:
- application/json
Date:
- Tue, 10 Mar 2026 07:40:05 GMT
Server:
- istio-envoy
Transfer-Encoding:
- chunked
Vary:
- Accept-Encoding
content-length:
- '114'
req-arrive-time:
- '1773128405828'
req-cost-time:
- '6'
resp-start-time:
- '1773128405835'
x-envoy-upstream-service-time:
- '6'
x-request-id:
- 575fd494-d816-48b6-92b3-8859550afb34
status:
code: 401
message: Unauthorized
- request:
body:
input:
messages:
- content: Test agent
role: system
- content: Trigger error
role: user
model: qwen-max
parameters:
incremental_output: true
result_format: message
stream: true
headers:
Accept:
- text/event-stream
Content-Type:
- application/json
X-Accel-Buffering:
- 'no'
X-DashScope-SSE:
- enable
authorization:
- Bearer test_api_key
user-agent:
- dashscope/1.25.13; python/3.14.0; platform/macOS-15.1.1-arm64-arm-64bit-Mach-O;
processor/arm; incremental_to_full/0
method: POST
uri: https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation
response:
body:
string: |-
{
"code": "InvalidApiKey",
"message": "Invalid API-key provided.",
"request_id": "c2936256-5f93-4309-b65d-2f94d03673de"
}
headers:
Content-Type:
- application/json
Date:
- Tue, 10 Mar 2026 07:40:05 GMT
Server:
- istio-envoy
Transfer-Encoding:
- chunked
Vary:
- Accept-Encoding
content-length:
- '114'
req-arrive-time:
- '1773128405986'
req-cost-time:
- '6'
resp-start-time:
- '1773128405992'
x-envoy-upstream-service-time:
- '6'
x-request-id:
- c2936256-5f93-4309-b65d-2f94d03673de
status:
code: 401
message: Unauthorized
version: 1
Loading
Loading