Skip to content

Commit f2e46b9

Browse files
committed
feat(agent): Update structured output tracing to agent level
1 parent cf37b62 commit f2e46b9

File tree

2 files changed

+81
-115
lines changed

2 files changed

+81
-115
lines changed

src/strands/agent/agent.py

Lines changed: 64 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -510,101 +510,84 @@ def capture_structured_output_hook(event: AfterToolInvocationEvent) -> None:
510510
if tool_input:
511511
captured_result = output_model(**tool_input)
512512

513-
# Add the callback temporarily (use add_callback, not add_hook)
514513
self.hooks.add_callback(AfterToolInvocationEvent, capture_structured_output_hook)
515514
added_callback = capture_structured_output_hook
516515

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

559-
if self.system_prompt:
560-
structured_output_span.add_event(
561-
"gen_ai.system.message",
562-
attributes={"role": "system", "content": serialize([{"text": self.system_prompt}])},
563-
)
530+
# Start agent trace span (same as stream_async)
531+
self.trace_span = self._start_agent_trace_span(message)
564532

565-
invocation_state = {
566-
"structured_output_mode": True,
567-
"structured_output_model": output_model,
568-
}
533+
try:
534+
with trace_api.use_span(self.trace_span):
535+
if not self.messages and not prompt:
536+
raise ValueError("No conversation history or prompt provided")
569537

570-
# Run the event loop
571-
async for event in self._run_loop(message=message, invocation_state=invocation_state):
572-
if "stop" in event:
573-
break
538+
invocation_state = {
539+
"structured_output_mode": True,
540+
"structured_output_model": output_model,
541+
}
574542

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

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

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

581+
except Exception as e:
582+
self._end_agent_trace_span(error=e)
583+
raise
602584
finally:
603585
# Clean up what we added - remove the callback
604-
if added_callback is not None and AfterToolInvocationEvent in self.hooks._registered_callbacks:
605-
callbacks = self.hooks._registered_callbacks[AfterToolInvocationEvent]
606-
if added_callback in callbacks:
607-
callbacks.remove(added_callback)
586+
if added_callback is not None:
587+
with suppress(ValueError, KeyError):
588+
callbacks = self.hooks._registered_callbacks.get(AfterToolInvocationEvent, [])
589+
if added_callback in callbacks:
590+
callbacks.remove(added_callback)
608591

609592
# Remove the tool we added
610593
if added_tool_name:

tests/strands/agent/test_agent.py

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

993993

994994
def test_agent_structured_output(agent, system_prompt, user, agenerator):
995-
# Setup mock tracer and span
996-
mock_strands_tracer = unittest.mock.MagicMock()
997-
mock_otel_tracer = unittest.mock.MagicMock()
995+
# Mock the agent tracing methods instead of direct OpenTelemetry calls
996+
agent._start_agent_trace_span = unittest.mock.Mock()
997+
agent._end_agent_trace_span = unittest.mock.Mock()
998998
mock_span = unittest.mock.MagicMock()
999-
mock_strands_tracer.tracer = mock_otel_tracer
1000-
mock_otel_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span
1001-
agent.tracer = mock_strands_tracer
999+
agent._start_agent_trace_span.return_value = mock_span
1000+
agent.trace_span = mock_span
10021001

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

@@ -1019,34 +1018,19 @@ def test_agent_structured_output(agent, system_prompt, user, agenerator):
10191018
type(user), [{"role": "user", "content": [{"text": prompt}]}], system_prompt=system_prompt
10201019
)
10211020

1022-
mock_span.set_attributes.assert_called_once_with(
1023-
{
1024-
"gen_ai.system": "strands-agents",
1025-
"gen_ai.agent.name": "Strands Agents",
1026-
"gen_ai.agent.id": "default",
1027-
"gen_ai.operation.name": "execute_structured_output",
1028-
}
1029-
)
1030-
1031-
mock_span.add_event.assert_any_call(
1032-
"gen_ai.user.message",
1033-
attributes={"role": "user", "content": '[{"text": "Jane Doe is 30 years old and her email is [email protected]"}]'},
1034-
)
1035-
1036-
mock_span.add_event.assert_called_with(
1037-
"gen_ai.choice",
1038-
attributes={"message": json.dumps(user.model_dump())},
1039-
)
1021+
# Verify agent-level tracing was called
1022+
agent._start_agent_trace_span.assert_called_once()
1023+
agent._end_agent_trace_span.assert_called_once()
10401024

10411025

10421026
def test_agent_structured_output_multi_modal_input(agent, system_prompt, user, agenerator):
1043-
# Setup mock tracer and span
1044-
mock_strands_tracer = unittest.mock.MagicMock()
1045-
mock_otel_tracer = unittest.mock.MagicMock()
1027+
# Mock the agent tracing methods instead of direct OpenTelemetry calls
1028+
agent._start_agent_trace_span = unittest.mock.Mock()
1029+
agent._end_agent_trace_span = unittest.mock.Mock()
10461030
mock_span = unittest.mock.MagicMock()
1047-
mock_strands_tracer.tracer = mock_otel_tracer
1048-
mock_otel_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span
1049-
agent.tracer = mock_strands_tracer
1031+
agent._start_agent_trace_span.return_value = mock_span
1032+
agent.trace_span = mock_span
1033+
10501034
agent.model.structured_output = unittest.mock.Mock(return_value=agenerator([{"output": user}]))
10511035

10521036
prompt = [
@@ -1076,10 +1060,9 @@ def test_agent_structured_output_multi_modal_input(agent, system_prompt, user, a
10761060
type(user), [{"role": "user", "content": prompt}], system_prompt=system_prompt
10771061
)
10781062

1079-
mock_span.add_event.assert_called_with(
1080-
"gen_ai.choice",
1081-
attributes={"message": json.dumps(user.model_dump())},
1082-
)
1063+
# Verify agent-level tracing was called
1064+
agent._start_agent_trace_span.assert_called_once()
1065+
agent._end_agent_trace_span.assert_called_once()
10831066

10841067

10851068
@pytest.mark.asyncio

0 commit comments

Comments
 (0)