Skip to content

Commit 01f7dab

Browse files
author
Kazmer, Nagy-Betegh
committed
feat(agent): Update structured output tracing to agent level
1 parent 23c8cb2 commit 01f7dab

File tree

2 files changed

+82
-116
lines changed

2 files changed

+82
-116
lines changed

src/strands/agent/agent.py

Lines changed: 65 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
from ..models.model import Model
3939
from ..session.session_manager import SessionManager
4040
from ..telemetry.metrics import EventLoopMetrics
41-
from ..telemetry.tracer import get_tracer, serialize
41+
from ..telemetry.tracer import get_tracer
4242
from ..tools.registry import ToolRegistry
4343
from ..tools.watcher import ToolWatcher
4444
from ..types.content import ContentBlock, Message, Messages
@@ -507,101 +507,84 @@ def capture_structured_output_hook(event: AfterToolInvocationEvent) -> None:
507507
if tool_input:
508508
captured_result = output_model(**tool_input)
509509

510-
# Add the callback temporarily (use add_callback, not add_hook)
511510
self.hooks.add_callback(AfterToolInvocationEvent, capture_structured_output_hook)
512511
added_callback = capture_structured_output_hook
513512

514-
try:
515-
with self.tracer.tracer.start_as_current_span(
516-
"execute_structured_output", kind=trace_api.SpanKind.CLIENT
517-
) as structured_output_span:
518-
try:
519-
if not self.messages and not prompt:
520-
raise ValueError("No conversation history or prompt provided")
521-
522-
# Create temporary messages array if prompt is provided
523-
message: Message
524-
if prompt:
525-
content: list[ContentBlock] = [{"text": prompt}] if isinstance(prompt, str) else prompt
526-
message = {"role": "user", "content": content}
527-
else:
528-
# Use existing conversation history
529-
message = {
530-
"role": "user",
531-
"content": [
532-
{
533-
"text": "Please provide the information from our conversation in the requested "
534-
"structured format."
535-
}
536-
],
537-
}
538-
539-
structured_output_span.set_attributes(
540-
{
541-
"gen_ai.system": "strands-agents",
542-
"gen_ai.agent.name": self.name,
543-
"gen_ai.agent.id": self.agent_id,
544-
"gen_ai.operation.name": "execute_structured_output",
545-
}
546-
)
547-
548-
# Add tracing for messages
549-
messages_to_trace = self.messages if not prompt else self.messages + [message]
550-
for msg in messages_to_trace:
551-
structured_output_span.add_event(
552-
f"gen_ai.{msg['role']}.message",
553-
attributes={"role": msg["role"], "content": serialize(msg["content"])},
554-
)
513+
# Create message for tracing
514+
message: Message
515+
if prompt:
516+
content: list[ContentBlock] = [{"text": prompt}] if isinstance(prompt, str) else prompt
517+
message = {"role": "user", "content": content}
518+
else:
519+
# Use existing conversation history
520+
message = {
521+
"role": "user",
522+
"content": [
523+
{"text": "Please provide the information from our conversation in the requested structured format."}
524+
],
525+
}
555526

556-
if self.system_prompt:
557-
structured_output_span.add_event(
558-
"gen_ai.system.message",
559-
attributes={"role": "system", "content": serialize([{"text": self.system_prompt}])},
560-
)
527+
# Start agent trace span (same as stream_async)
528+
self.trace_span = self._start_agent_trace_span(message)
561529

562-
invocation_state = {
563-
"structured_output_mode": True,
564-
"structured_output_model": output_model,
565-
}
530+
try:
531+
with trace_api.use_span(self.trace_span):
532+
if not self.messages and not prompt:
533+
raise ValueError("No conversation history or prompt provided")
566534

567-
# Run the event loop
568-
async for event in self._run_loop(message=message, invocation_state=invocation_state):
569-
if "stop" in event:
570-
break
535+
invocation_state = {
536+
"structured_output_mode": True,
537+
"structured_output_model": output_model,
538+
}
571539

572-
# Return the captured structured result if we got it from the tool
573-
if captured_result:
574-
structured_output_span.add_event(
575-
"gen_ai.choice", attributes={"message": serialize(captured_result.model_dump())}
540+
# Run the event loop
541+
async for event in self._run_loop(message=message, invocation_state=invocation_state):
542+
if "stop" in event:
543+
break
544+
545+
# Return the captured structured result if we got it from the tool
546+
if captured_result:
547+
self._end_agent_trace_span(
548+
response=AgentResult(
549+
message={"role": "assistant", "content": [{"text": str(captured_result)}]},
550+
stop_reason="end_turn",
551+
metrics=self.event_loop_metrics,
552+
state={},
576553
)
577-
return captured_result
578-
579-
# Fallback: Use the original model.structured_output approach
580-
# This maintains backward compatibility with existing tests and implementations
581-
# Use original_messages to get clean message state, or self.messages if preserve_conversation=True
582-
base_messages = original_messages if original_messages is not None else self.messages
583-
temp_messages = base_messages if not prompt else base_messages + [message]
554+
)
555+
return captured_result
584556

585-
events = self.model.structured_output(output_model, temp_messages, system_prompt=self.system_prompt)
586-
async for event in events:
587-
if "callback" in event:
588-
self.callback_handler(**cast(dict, event["callback"]))
557+
# Fallback: Use the original model.structured_output approach
558+
# This maintains backward compatibility with existing tests and implementations
559+
# Use original_messages to get clean message state, or self.messages if preserve_conversation=True
560+
base_messages = original_messages if original_messages is not None else self.messages
561+
temp_messages = base_messages if not prompt else base_messages + [message]
589562

590-
structured_output_span.add_event(
591-
"gen_ai.choice", attributes={"message": serialize(event["output"].model_dump())}
563+
events = self.model.structured_output(output_model, temp_messages, system_prompt=self.system_prompt)
564+
async for event in events:
565+
if "callback" in event:
566+
self.callback_handler(**cast(dict, event["callback"]))
567+
568+
self._end_agent_trace_span(
569+
response=AgentResult(
570+
message={"role": "assistant", "content": [{"text": str(event["output"])}]},
571+
stop_reason="end_turn",
572+
metrics=self.event_loop_metrics,
573+
state={},
592574
)
593-
return cast(T, event["output"])
594-
595-
except Exception as e:
596-
structured_output_span.record_exception(e)
597-
raise
575+
)
576+
return cast(T, event["output"])
598577

578+
except Exception as e:
579+
self._end_agent_trace_span(error=e)
580+
raise
599581
finally:
600582
# Clean up what we added - remove the callback
601-
if added_callback is not None and AfterToolInvocationEvent in self.hooks._registered_callbacks:
602-
callbacks = self.hooks._registered_callbacks[AfterToolInvocationEvent]
603-
if added_callback in callbacks:
604-
callbacks.remove(added_callback)
583+
if added_callback is not None:
584+
with suppress(ValueError, KeyError):
585+
callbacks = self.hooks._registered_callbacks.get(AfterToolInvocationEvent, [])
586+
if added_callback in callbacks:
587+
callbacks.remove(added_callback)
605588

606589
# Remove the tool we added
607590
if added_tool_name:

tests/strands/agent/test_agent.py

Lines changed: 17 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -980,13 +980,12 @@ def test_agent_callback_handler_custom_handler_used():
980980

981981

982982
def test_agent_structured_output(agent, system_prompt, user, agenerator):
983-
# Setup mock tracer and span
984-
mock_strands_tracer = unittest.mock.MagicMock()
985-
mock_otel_tracer = unittest.mock.MagicMock()
983+
# Mock the agent tracing methods instead of direct OpenTelemetry calls
984+
agent._start_agent_trace_span = unittest.mock.Mock()
985+
agent._end_agent_trace_span = unittest.mock.Mock()
986986
mock_span = unittest.mock.MagicMock()
987-
mock_strands_tracer.tracer = mock_otel_tracer
988-
mock_otel_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span
989-
agent.tracer = mock_strands_tracer
987+
agent._start_agent_trace_span.return_value = mock_span
988+
agent.trace_span = mock_span
990989

991990
agent.model.structured_output = unittest.mock.Mock(return_value=agenerator([{"output": user}]))
992991

@@ -1007,34 +1006,19 @@ def test_agent_structured_output(agent, system_prompt, user, agenerator):
10071006
type(user), [{"role": "user", "content": [{"text": prompt}]}], system_prompt=system_prompt
10081007
)
10091008

1010-
mock_span.set_attributes.assert_called_once_with(
1011-
{
1012-
"gen_ai.system": "strands-agents",
1013-
"gen_ai.agent.name": "Strands Agents",
1014-
"gen_ai.agent.id": "default",
1015-
"gen_ai.operation.name": "execute_structured_output",
1016-
}
1017-
)
1018-
1019-
mock_span.add_event.assert_any_call(
1020-
"gen_ai.user.message",
1021-
attributes={"role": "user", "content": '[{"text": "Jane Doe is 30 years old and her email is [email protected]"}]'},
1022-
)
1023-
1024-
mock_span.add_event.assert_called_with(
1025-
"gen_ai.choice",
1026-
attributes={"message": json.dumps(user.model_dump())},
1027-
)
1009+
# Verify agent-level tracing was called
1010+
agent._start_agent_trace_span.assert_called_once()
1011+
agent._end_agent_trace_span.assert_called_once()
10281012

10291013

10301014
def test_agent_structured_output_multi_modal_input(agent, system_prompt, user, agenerator):
1031-
# Setup mock tracer and span
1032-
mock_strands_tracer = unittest.mock.MagicMock()
1033-
mock_otel_tracer = unittest.mock.MagicMock()
1015+
# Mock the agent tracing methods instead of direct OpenTelemetry calls
1016+
agent._start_agent_trace_span = unittest.mock.Mock()
1017+
agent._end_agent_trace_span = unittest.mock.Mock()
10341018
mock_span = unittest.mock.MagicMock()
1035-
mock_strands_tracer.tracer = mock_otel_tracer
1036-
mock_otel_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span
1037-
agent.tracer = mock_strands_tracer
1019+
agent._start_agent_trace_span.return_value = mock_span
1020+
agent.trace_span = mock_span
1021+
10381022
agent.model.structured_output = unittest.mock.Mock(return_value=agenerator([{"output": user}]))
10391023

10401024
prompt = [
@@ -1064,10 +1048,9 @@ def test_agent_structured_output_multi_modal_input(agent, system_prompt, user, a
10641048
type(user), [{"role": "user", "content": prompt}], system_prompt=system_prompt
10651049
)
10661050

1067-
mock_span.add_event.assert_called_with(
1068-
"gen_ai.choice",
1069-
attributes={"message": json.dumps(user.model_dump())},
1070-
)
1051+
# Verify agent-level tracing was called
1052+
agent._start_agent_trace_span.assert_called_once()
1053+
agent._end_agent_trace_span.assert_called_once()
10711054

10721055

10731056
@pytest.mark.asyncio

0 commit comments

Comments
 (0)