Skip to content

Commit 15d683f

Browse files
feat(agentscope): Add ReAct Span Support (#140)
Co-authored-by: Minghui Zhang <84360903+Cirilla-zmh@users.noreply.github.com>
1 parent fadf0f1 commit 15d683f

File tree

10 files changed

+1194
-0
lines changed

10 files changed

+1194
-0
lines changed

instrumentation-loongsuite/loongsuite-instrumentation-agentscope/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Added
11+
12+
- Add ReAct step span instrumentation for ReAct agents
13+
([#140](https://github.com/alibaba/loongsuite-python-agent/pull/140))
14+
- Each ReAct iteration is wrapped in a `react step` span with `gen_ai.react.round` and `gen_ai.react.finish_reason` attributes
15+
- Uses AgentScope's instance-level hook system for robust, non-invasive instrumentation
16+
1017
## Version 0.2.0 (2026-03-12)
1118

1219
## Version 0.1.0 (2026-02-28)

instrumentation-loongsuite/loongsuite-instrumentation-agentscope/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=NO_CONTENT
9090

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

instrumentation-loongsuite/loongsuite-instrumentation-agentscope/src/opentelemetry/instrumentation/agentscope/_wrapper.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,15 @@
1818

1919
import logging
2020
import timeit
21+
import uuid
22+
from dataclasses import dataclass, field
2123
from functools import wraps
2224
from typing import Any, AsyncGenerator
2325

26+
from opentelemetry.context import get_current as _get_current_context
27+
from opentelemetry.util.genai._extended_common.common_types import (
28+
ReactStepInvocation,
29+
)
2430
from opentelemetry.util.genai.extended_handler import ExtendedTelemetryHandler
2531
from opentelemetry.util.genai.types import Error, LLMInvocation
2632

@@ -34,6 +40,134 @@
3440

3541
logger = logging.getLogger(__name__)
3642

43+
_REACT_STEP_HOOK_PREFIX = "otel_react_step"
44+
45+
46+
def _is_react_agent(agent_instance: Any) -> bool:
47+
"""Check if an agent instance is a ReAct agent by duck-typing."""
48+
return hasattr(agent_instance, "_instance_pre_reasoning_hooks")
49+
50+
51+
@dataclass
52+
class _ReactStepState:
53+
"""Per-agent-call state for React step span lifecycle."""
54+
55+
hook_name: str = field(
56+
default_factory=lambda: f"{_REACT_STEP_HOOK_PREFIX}_{uuid.uuid4().hex[:8]}"
57+
)
58+
react_round: int = 0
59+
active_step: ReactStepInvocation | None = None
60+
original_context: Any = field(default=None)
61+
pending_acting_count: int = 0
62+
63+
64+
def _make_pre_reasoning_hook(
65+
handler: ExtendedTelemetryHandler,
66+
) -> Any:
67+
"""Create a pre_reasoning hook that opens a new React step span.
68+
69+
Also closes any leftover step from a previous iteration as a fallback
70+
(normal path closes via post_acting).
71+
"""
72+
73+
def hook(agent_self: Any, kwargs: dict) -> None:
74+
state: _ReactStepState | None = getattr(
75+
agent_self, "_react_step_state", None
76+
)
77+
if state is None:
78+
return None
79+
80+
if state.active_step:
81+
state.active_step.finish_reason = "tool_calls"
82+
handler.stop_react_step(state.active_step)
83+
state.active_step = None
84+
85+
state.react_round += 1
86+
inv = ReactStepInvocation(round=state.react_round)
87+
handler.start_react_step(inv, context=state.original_context)
88+
state.active_step = inv
89+
state.pending_acting_count = 0
90+
return None
91+
92+
return hook
93+
94+
95+
def _make_post_reasoning_hook(
96+
handler: ExtendedTelemetryHandler,
97+
) -> Any:
98+
"""Create a post_reasoning hook that counts tool_use blocks
99+
to initialize the pending_acting_count for the current step."""
100+
101+
def hook(agent_self: Any, kwargs: dict, output: Any) -> None:
102+
state: _ReactStepState | None = getattr(
103+
agent_self, "_react_step_state", None
104+
)
105+
if state is None or output is None:
106+
return None
107+
108+
tool_blocks = (
109+
output.get_content_blocks("tool_use")
110+
if hasattr(output, "get_content_blocks")
111+
else []
112+
)
113+
state.pending_acting_count = len(tool_blocks)
114+
return None
115+
116+
return hook
117+
118+
119+
def _make_post_acting_hook(
120+
handler: ExtendedTelemetryHandler,
121+
) -> Any:
122+
"""Create a post_acting hook that decrements pending_acting_count
123+
and closes the step span when all acting calls are done."""
124+
125+
def hook(agent_self: Any, kwargs: dict, output: Any) -> None:
126+
state: _ReactStepState | None = getattr(
127+
agent_self, "_react_step_state", None
128+
)
129+
if state is None or state.active_step is None:
130+
return None
131+
132+
state.pending_acting_count -= 1
133+
if state.pending_acting_count <= 0:
134+
state.active_step.finish_reason = "tool_calls"
135+
handler.stop_react_step(state.active_step)
136+
state.active_step = None
137+
return None
138+
139+
return hook
140+
141+
142+
def _register_react_hooks(
143+
agent: Any, state: _ReactStepState, handler: ExtendedTelemetryHandler
144+
) -> None:
145+
"""Register React step tracking hooks on an agent instance."""
146+
agent.register_instance_hook(
147+
"pre_reasoning",
148+
state.hook_name,
149+
_make_pre_reasoning_hook(handler),
150+
)
151+
agent.register_instance_hook(
152+
"post_reasoning",
153+
state.hook_name,
154+
_make_post_reasoning_hook(handler),
155+
)
156+
agent.register_instance_hook(
157+
"post_acting",
158+
state.hook_name,
159+
_make_post_acting_hook(handler),
160+
)
161+
162+
163+
def _remove_react_hooks(agent: Any, state: _ReactStepState) -> None:
164+
"""Remove React step tracking hooks from an agent instance."""
165+
for hook_type in ("pre_reasoning", "post_reasoning", "post_acting"):
166+
try:
167+
agent.remove_instance_hook(hook_type, state.hook_name)
168+
except (ValueError, KeyError):
169+
pass
170+
37171

38172
class AgentScopeChatModelWrapper:
39173
"""Wrapper for ChatModelBase that hijacks __init__ to replace __call__."""
@@ -228,11 +362,25 @@ async def async_wrapped_call(
228362
function_name = f"{call_self.__class__.__name__}.__call__"
229363
invocation.attributes["rpc"] = function_name
230364

365+
is_react = _is_react_agent(call_self)
366+
state: _ReactStepState | None = None
367+
if is_react:
368+
state = _ReactStepState(
369+
original_context=_get_current_context(),
370+
)
371+
call_self._react_step_state = state
372+
_register_react_hooks(call_self, state, self._handler)
373+
231374
try:
232375
result = await original_call(
233376
call_self, *call_args, **call_kwargs
234377
)
235378

379+
if is_react and state and state.active_step:
380+
state.active_step.finish_reason = "stop"
381+
self._handler.stop_react_step(state.active_step)
382+
state.active_step = None
383+
236384
invocation.output_messages = (
237385
convert_agent_response_to_output_messages(result)
238386
)
@@ -244,11 +392,24 @@ async def async_wrapped_call(
244392
return result
245393

246394
except Exception as e:
395+
if is_react and state and state.active_step:
396+
self._handler.fail_react_step(
397+
state.active_step,
398+
Error(message=str(e), type=type(e)),
399+
)
400+
state.active_step = None
401+
247402
self._handler.fail_invoke_agent(
248403
invocation, Error(message=str(e), type=type(e))
249404
)
250405
raise
251406

407+
finally:
408+
if is_react and state:
409+
_remove_react_hooks(call_self, state)
410+
if hasattr(call_self, "_react_step_state"):
411+
del call_self._react_step_state
412+
252413
except Exception as e:
253414
logger.exception("Error in agent instrumentation: %s", e)
254415
return await original_call(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
interactions:
2+
- request:
3+
body:
4+
input:
5+
messages:
6+
- content: Test agent
7+
role: system
8+
- content: Trigger error
9+
role: user
10+
model: qwen-max
11+
parameters:
12+
incremental_output: true
13+
result_format: message
14+
stream: true
15+
headers:
16+
Accept:
17+
- text/event-stream
18+
Content-Type:
19+
- application/json
20+
X-Accel-Buffering:
21+
- 'no'
22+
X-DashScope-SSE:
23+
- enable
24+
authorization:
25+
- Bearer test_api_key
26+
user-agent:
27+
- dashscope/1.25.13; python/3.14.0; platform/macOS-15.1.1-arm64-arm-64bit-Mach-O;
28+
processor/arm; incremental_to_full/0
29+
method: POST
30+
uri: https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation
31+
response:
32+
body:
33+
string: |-
34+
{
35+
"code": "InvalidApiKey",
36+
"message": "Invalid API-key provided.",
37+
"request_id": "575fd494-d816-48b6-92b3-8859550afb34"
38+
}
39+
headers:
40+
Content-Type:
41+
- application/json
42+
Date:
43+
- Tue, 10 Mar 2026 07:40:05 GMT
44+
Server:
45+
- istio-envoy
46+
Transfer-Encoding:
47+
- chunked
48+
Vary:
49+
- Accept-Encoding
50+
content-length:
51+
- '114'
52+
req-arrive-time:
53+
- '1773128405828'
54+
req-cost-time:
55+
- '6'
56+
resp-start-time:
57+
- '1773128405835'
58+
x-envoy-upstream-service-time:
59+
- '6'
60+
x-request-id:
61+
- 575fd494-d816-48b6-92b3-8859550afb34
62+
status:
63+
code: 401
64+
message: Unauthorized
65+
- request:
66+
body:
67+
input:
68+
messages:
69+
- content: Test agent
70+
role: system
71+
- content: Trigger error
72+
role: user
73+
model: qwen-max
74+
parameters:
75+
incremental_output: true
76+
result_format: message
77+
stream: true
78+
headers:
79+
Accept:
80+
- text/event-stream
81+
Content-Type:
82+
- application/json
83+
X-Accel-Buffering:
84+
- 'no'
85+
X-DashScope-SSE:
86+
- enable
87+
authorization:
88+
- Bearer test_api_key
89+
user-agent:
90+
- dashscope/1.25.13; python/3.14.0; platform/macOS-15.1.1-arm64-arm-64bit-Mach-O;
91+
processor/arm; incremental_to_full/0
92+
method: POST
93+
uri: https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation
94+
response:
95+
body:
96+
string: |-
97+
{
98+
"code": "InvalidApiKey",
99+
"message": "Invalid API-key provided.",
100+
"request_id": "c2936256-5f93-4309-b65d-2f94d03673de"
101+
}
102+
headers:
103+
Content-Type:
104+
- application/json
105+
Date:
106+
- Tue, 10 Mar 2026 07:40:05 GMT
107+
Server:
108+
- istio-envoy
109+
Transfer-Encoding:
110+
- chunked
111+
Vary:
112+
- Accept-Encoding
113+
content-length:
114+
- '114'
115+
req-arrive-time:
116+
- '1773128405986'
117+
req-cost-time:
118+
- '6'
119+
resp-start-time:
120+
- '1773128405992'
121+
x-envoy-upstream-service-time:
122+
- '6'
123+
x-request-id:
124+
- c2936256-5f93-4309-b65d-2f94d03673de
125+
status:
126+
code: 401
127+
message: Unauthorized
128+
version: 1

0 commit comments

Comments
 (0)