From d6b371a2bfc072dbff8dd024f99d75da4c7195c8 Mon Sep 17 00:00:00 2001 From: strands-agent <217235299+strands-agent@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:19:40 +0000 Subject: [PATCH 1/8] fix: include both text and structured_output in AgentResult.__str__ (Option 1) Fixes #1461: Structured output is not properly propagated in the graph This implements Option 1 from #1461 discussion - modifying __str__ to include both text and serialized structured_output when both exist. Changes: - Updated AgentResult.__str__ to append structured output when present - Format: text content followed by [Structured Output] section with JSON - Updated existing test to reflect new expected behavior - Added 8 comprehensive tests for all __str__ scenarios The fix ensures that when AgentResult is stringified (e.g., in graph node transitions), the structured output data is preserved alongside any text content. Requested-by: @afarntrog --- src/strands/agent/agent_result.py | 19 ++- tests/strands/agent/test_agent_result.py | 11 +- tests/strands/agent/test_agent_result_str.py | 149 +++++++++++++++++++ 3 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 tests/strands/agent/test_agent_result_str.py diff --git a/src/strands/agent/agent_result.py b/src/strands/agent/agent_result.py index 8f9241a67..1caa061f1 100644 --- a/src/strands/agent/agent_result.py +++ b/src/strands/agent/agent_result.py @@ -38,12 +38,12 @@ class AgentResult: def __str__(self) -> str: """Get the agent's last message as a string. - This method extracts and concatenates all text content from the final message, ignoring any non-text content - like images or structured data. If there's no text content but structured output is present, it serializes - the structured output instead. + This method extracts and concatenates all text content from the final message. + If structured output is present, it is always appended to the result (serialized as JSON), + ensuring both text and structured data are included when both exist. Returns: - The agent's last message as a string. + The agent's last message as a string, including any structured output. """ content_array = self.message.get("content", []) @@ -59,8 +59,15 @@ def __str__(self) -> str: if isinstance(content, dict) and "text" in content: result += content.get("text", "") + "\n" - if not result and self.structured_output: - result = self.structured_output.model_dump_json() + # Always include structured output when present (Option 1 from #1461) + if self.structured_output: + structured_json = self.structured_output.model_dump_json() + if result: + # Both text and structured output exist - include both + result = result.rstrip("\n") + "\n\n[Structured Output]\n" + structured_json + "\n" + else: + # Only structured output exists + result = structured_json return result diff --git a/tests/strands/agent/test_agent_result.py b/tests/strands/agent/test_agent_result.py index 6e4c2c91a..5a120c571 100644 --- a/tests/strands/agent/test_agent_result.py +++ b/tests/strands/agent/test_agent_result.py @@ -185,7 +185,7 @@ def test__init__structured_output_defaults_to_none(mock_metrics, simple_message: def test__str__with_structured_output(mock_metrics, simple_message: Message): - """Test that str() is not affected by structured_output.""" + """Test that str() includes BOTH text and structured_output (Option 1 fix for #1461).""" structured_output = StructuredOutputModel(name="test", value=42) result = AgentResult( @@ -196,11 +196,12 @@ def test__str__with_structured_output(mock_metrics, simple_message: Message): structured_output=structured_output, ) - # The string representation should only include the message text, not structured output + # Option 1: str() should now include BOTH text AND structured output message_string = str(result) - assert message_string == "Hello world!\n" - assert "test" not in message_string - assert "42" not in message_string + assert "Hello world!" in message_string + assert "[Structured Output]" in message_string + assert "test" in message_string + assert "42" in message_string def test__str__empty_message_with_structured_output(mock_metrics, empty_message: Message): diff --git a/tests/strands/agent/test_agent_result_str.py b/tests/strands/agent/test_agent_result_str.py new file mode 100644 index 000000000..0384c2402 --- /dev/null +++ b/tests/strands/agent/test_agent_result_str.py @@ -0,0 +1,149 @@ +"""Tests for AgentResult.__str__ method with Option 1 behavior. + +This module tests that __str__ properly includes both text and structured output +when both exist (fix for issue #1461). +""" + +import pytest +from pydantic import BaseModel + +from strands.agent.agent_result import AgentResult +from strands.telemetry.metrics import EventLoopMetrics + + +class SampleOutput(BaseModel): + """Sample structured output model for testing.""" + + name: str + value: int + + +class TestAgentResultStrOption1: + """Tests for Option 1 behavior: __str__ includes both text and structured output.""" + + def test_str_text_only(self): + """Test __str__ with only text content.""" + result = AgentResult( + stop_reason="end_turn", + message={"role": "assistant", "content": [{"text": "Hello world"}]}, + metrics=EventLoopMetrics(), + state={}, + ) + assert str(result) == "Hello world\n" + + def test_str_structured_output_only(self): + """Test __str__ with only structured output (no text).""" + structured = SampleOutput(name="test", value=42) + result = AgentResult( + stop_reason="end_turn", + message={"role": "assistant", "content": []}, + metrics=EventLoopMetrics(), + state={}, + structured_output=structured, + ) + assert str(result) == '{"name":"test","value":42}' + + def test_str_both_text_and_structured_output(self): + """Test __str__ includes BOTH text and structured output when both exist. + + This is the key fix for issue #1461 - Option 1. + """ + structured = SampleOutput(name="test", value=42) + result = AgentResult( + stop_reason="end_turn", + message={"role": "assistant", "content": [{"text": "Here is the analysis"}]}, + metrics=EventLoopMetrics(), + state={}, + structured_output=structured, + ) + output = str(result) + # Should include both text AND structured output + assert "Here is the analysis" in output + assert "[Structured Output]" in output + assert '{"name":"test","value":42}' in output + + def test_str_multiple_text_blocks_with_structured_output(self): + """Test __str__ with multiple text blocks and structured output.""" + structured = SampleOutput(name="multi", value=100) + result = AgentResult( + stop_reason="end_turn", + message={ + "role": "assistant", + "content": [ + {"text": "First paragraph."}, + {"text": "Second paragraph."}, + ], + }, + metrics=EventLoopMetrics(), + state={}, + structured_output=structured, + ) + output = str(result) + assert "First paragraph." in output + assert "Second paragraph." in output + assert "[Structured Output]" in output + assert '{"name":"multi","value":100}' in output + + def test_str_empty_message_no_structured_output(self): + """Test __str__ with empty message and no structured output.""" + result = AgentResult( + stop_reason="end_turn", + message={"role": "assistant", "content": []}, + metrics=EventLoopMetrics(), + state={}, + ) + assert str(result) == "" + + def test_str_non_text_content_only(self): + """Test __str__ with only non-text content (e.g., toolUse).""" + result = AgentResult( + stop_reason="tool_use", + message={ + "role": "assistant", + "content": [{"toolUse": {"toolUseId": "123", "name": "test_tool", "input": {}}}], + }, + metrics=EventLoopMetrics(), + state={}, + ) + assert str(result) == "" + + def test_str_mixed_content_with_structured_output(self): + """Test __str__ with mixed content (text + toolUse) and structured output.""" + structured = SampleOutput(name="mixed", value=50) + result = AgentResult( + stop_reason="end_turn", + message={ + "role": "assistant", + "content": [ + {"text": "Processing complete."}, + {"toolUse": {"toolUseId": "456", "name": "helper", "input": {}}}, + ], + }, + metrics=EventLoopMetrics(), + state={}, + structured_output=structured, + ) + output = str(result) + assert "Processing complete." in output + assert "[Structured Output]" in output + assert '{"name":"mixed","value":50}' in output + # toolUse should not appear in string output + assert "toolUse" not in output + assert "helper" not in output + + def test_str_format_structure(self): + """Test the exact format of __str__ output with both text and structured output.""" + structured = SampleOutput(name="format", value=99) + result = AgentResult( + stop_reason="end_turn", + message={"role": "assistant", "content": [{"text": "Result text"}]}, + metrics=EventLoopMetrics(), + state={}, + structured_output=structured, + ) + output = str(result) + # Verify the format: text followed by structured output section + lines = output.strip().split("\n") + assert lines[0] == "Result text" + assert "[Structured Output]" in output + assert lines[-1] == '{"name":"format","value":99}' From 09ed64880d4c8be9bbf3a9149fbed0a1d2550ab9 Mon Sep 17 00:00:00 2001 From: strands-agent Date: Tue, 13 Jan 2026 17:38:06 +0000 Subject: [PATCH 2/8] fix: output structured output in JSON format for parseability Addresses feedback from @afarntrog to make the output JSON-parseable. When both text and structured output exist, output is now: {"text": "...", "structured_output": {...}} This allows users to parse the combined output with json.loads() and extract both the text and structured data programmatically. - Text-only: returns raw text (unchanged) - Structured-only: returns JSON of structured output (unchanged) - Both: returns JSON with 'text' and 'structured_output' keys (NEW) --- src/strands/agent/agent_result.py | 27 +++++--- tests/strands/agent/test_agent_result.py | 14 ++-- tests/strands/agent/test_agent_result_str.py | 67 ++++++++++++-------- 3 files changed, 66 insertions(+), 42 deletions(-) diff --git a/src/strands/agent/agent_result.py b/src/strands/agent/agent_result.py index 1caa061f1..d5a0826bd 100644 --- a/src/strands/agent/agent_result.py +++ b/src/strands/agent/agent_result.py @@ -3,6 +3,7 @@ This module defines the AgentResult class which encapsulates the complete response from an agent's processing cycle. """ +import json from collections.abc import Sequence from dataclasses import dataclass from typing import Any, cast @@ -38,9 +39,15 @@ class AgentResult: def __str__(self) -> str: """Get the agent's last message as a string. - This method extracts and concatenates all text content from the final message. - If structured output is present, it is always appended to the result (serialized as JSON), - ensuring both text and structured data are included when both exist. + This method extracts and concatenates all text content from the final message, + including text from both "text" blocks and "citationsContent" blocks. + + When both text and structured output exist, the output is JSON-formatted so users + can parse it programmatically: + {"text": "...", "structured_output": {...}} + + When only text exists, returns the raw text. + When only structured output exists, returns the JSON of the structured output. Returns: The agent's last message as a string, including any structured output. @@ -59,15 +66,17 @@ def __str__(self) -> str: if isinstance(content, dict) and "text" in content: result += content.get("text", "") + "\n" - # Always include structured output when present (Option 1 from #1461) + # Handle structured output if self.structured_output: - structured_json = self.structured_output.model_dump_json() + structured_data = self.structured_output.model_dump() if result: - # Both text and structured output exist - include both - result = result.rstrip("\n") + "\n\n[Structured Output]\n" + structured_json + "\n" + # Both text and structured output exist - return JSON-parseable format + # Preserve text as-is (don't strip) + combined = {"text": result, "structured_output": structured_data} + return json.dumps(combined) else: - # Only structured output exists - result = structured_json + # Only structured output exists - return just the structured output JSON + return self.structured_output.model_dump_json() return result diff --git a/tests/strands/agent/test_agent_result.py b/tests/strands/agent/test_agent_result.py index 5a120c571..b59b24a1c 100644 --- a/tests/strands/agent/test_agent_result.py +++ b/tests/strands/agent/test_agent_result.py @@ -1,3 +1,4 @@ +import json import unittest.mock from typing import cast @@ -185,7 +186,7 @@ def test__init__structured_output_defaults_to_none(mock_metrics, simple_message: def test__str__with_structured_output(mock_metrics, simple_message: Message): - """Test that str() includes BOTH text and structured_output (Option 1 fix for #1461).""" + """Test that str() includes BOTH text and structured_output in JSON format (Option 1 fix for #1461).""" structured_output = StructuredOutputModel(name="test", value=42) result = AgentResult( @@ -196,12 +197,13 @@ def test__str__with_structured_output(mock_metrics, simple_message: Message): structured_output=structured_output, ) - # Option 1: str() should now include BOTH text AND structured output + # Option 1: str() should now include BOTH text AND structured output in JSON format message_string = str(result) - assert "Hello world!" in message_string - assert "[Structured Output]" in message_string - assert "test" in message_string - assert "42" in message_string + # Output should be valid JSON + parsed = json.loads(message_string) + assert parsed["text"] == "Hello world!" + assert parsed["structured_output"]["name"] == "test" + assert parsed["structured_output"]["value"] == 42 def test__str__empty_message_with_structured_output(mock_metrics, empty_message: Message): diff --git a/tests/strands/agent/test_agent_result_str.py b/tests/strands/agent/test_agent_result_str.py index 0384c2402..123b4b447 100644 --- a/tests/strands/agent/test_agent_result_str.py +++ b/tests/strands/agent/test_agent_result_str.py @@ -2,9 +2,15 @@ This module tests that __str__ properly includes both text and structured output when both exist (fix for issue #1461). + +The output format when both text and structured output exist is JSON: +{"text": "...", "structured_output": {...}} + +This allows users to parse the output programmatically. """ -import pytest +import json + from pydantic import BaseModel from strands.agent.agent_result import AgentResult @@ -19,7 +25,7 @@ class SampleOutput(BaseModel): class TestAgentResultStrOption1: - """Tests for Option 1 behavior: __str__ includes both text and structured output.""" + """Tests for Option 1 behavior: __str__ includes both text and structured output in JSON format.""" def test_str_text_only(self): """Test __str__ with only text content.""" @@ -45,8 +51,9 @@ def test_str_structured_output_only(self): def test_str_both_text_and_structured_output(self): """Test __str__ includes BOTH text and structured output when both exist. - - This is the key fix for issue #1461 - Option 1. + + This is the key fix for issue #1461 - Option 1 with JSON format. + Output should be JSON-parseable. """ structured = SampleOutput(name="test", value=42) result = AgentResult( @@ -57,10 +64,11 @@ def test_str_both_text_and_structured_output(self): structured_output=structured, ) output = str(result) - # Should include both text AND structured output - assert "Here is the analysis" in output - assert "[Structured Output]" in output - assert '{"name":"test","value":42}' in output + # Output should be valid JSON + parsed = json.loads(output) + assert parsed["text"] == "Here is the analysis" + assert parsed["structured_output"]["name"] == "test" + assert parsed["structured_output"]["value"] == 42 def test_str_multiple_text_blocks_with_structured_output(self): """Test __str__ with multiple text blocks and structured output.""" @@ -79,10 +87,12 @@ def test_str_multiple_text_blocks_with_structured_output(self): structured_output=structured, ) output = str(result) - assert "First paragraph." in output - assert "Second paragraph." in output - assert "[Structured Output]" in output - assert '{"name":"multi","value":100}' in output + # Output should be valid JSON + parsed = json.loads(output) + assert "First paragraph." in parsed["text"] + assert "Second paragraph." in parsed["text"] + assert parsed["structured_output"]["name"] == "multi" + assert parsed["structured_output"]["value"] == 100 def test_str_empty_message_no_structured_output(self): """Test __str__ with empty message and no structured output.""" @@ -124,16 +134,18 @@ def test_str_mixed_content_with_structured_output(self): structured_output=structured, ) output = str(result) - assert "Processing complete." in output - assert "[Structured Output]" in output - assert '{"name":"mixed","value":50}' in output - # toolUse should not appear in string output - assert "toolUse" not in output - assert "helper" not in output - - def test_str_format_structure(self): - """Test the exact format of __str__ output with both text and structured output.""" - structured = SampleOutput(name="format", value=99) + # Output should be valid JSON + parsed = json.loads(output) + assert parsed["text"] == "Processing complete." + assert parsed["structured_output"]["name"] == "mixed" + assert parsed["structured_output"]["value"] == 50 + # toolUse should not appear in the text + assert "toolUse" not in parsed["text"] + assert "helper" not in parsed["text"] + + def test_str_json_parseable(self): + """Test that output with both text and structured output is JSON-parseable.""" + structured = SampleOutput(name="parseable", value=99) result = AgentResult( stop_reason="end_turn", message={"role": "assistant", "content": [{"text": "Result text"}]}, @@ -142,8 +154,9 @@ def test_str_format_structure(self): structured_output=structured, ) output = str(result) - # Verify the format: text followed by structured output section - lines = output.strip().split("\n") - assert lines[0] == "Result text" - assert "[Structured Output]" in output - assert lines[-1] == '{"name":"format","value":99}' + # Should be valid JSON that can be parsed + parsed = json.loads(output) + assert "text" in parsed + assert "structured_output" in parsed + assert parsed["text"] == "Result text" + assert parsed["structured_output"] == {"name": "parseable", "value": 99} From 047eab1871cba39542a546aef31d663f9d28e006 Mon Sep 17 00:00:00 2001 From: strands-agent <217235299+strands-agent@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:04:52 +0000 Subject: [PATCH 3/8] chore: remove Option 1 references from comments Address review feedback from @cagataycali to clean up code comments. --- src/strands/agent/agent_result.py | 2 +- tests/strands/agent/test_agent_result.py | 4 ++-- tests/strands/agent/test_agent_result_str.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/strands/agent/agent_result.py b/src/strands/agent/agent_result.py index d5a0826bd..2121cc694 100644 --- a/src/strands/agent/agent_result.py +++ b/src/strands/agent/agent_result.py @@ -66,7 +66,7 @@ def __str__(self) -> str: if isinstance(content, dict) and "text" in content: result += content.get("text", "") + "\n" - # Handle structured output + # Always include structured output when present if self.structured_output: structured_data = self.structured_output.model_dump() if result: diff --git a/tests/strands/agent/test_agent_result.py b/tests/strands/agent/test_agent_result.py index b59b24a1c..44ff24bce 100644 --- a/tests/strands/agent/test_agent_result.py +++ b/tests/strands/agent/test_agent_result.py @@ -186,7 +186,7 @@ def test__init__structured_output_defaults_to_none(mock_metrics, simple_message: def test__str__with_structured_output(mock_metrics, simple_message: Message): - """Test that str() includes BOTH text and structured_output in JSON format (Option 1 fix for #1461).""" + """Test that str() includes BOTH text and structured_output in JSON format.""" structured_output = StructuredOutputModel(name="test", value=42) result = AgentResult( @@ -197,7 +197,7 @@ def test__str__with_structured_output(mock_metrics, simple_message: Message): structured_output=structured_output, ) - # Option 1: str() should now include BOTH text AND structured output in JSON format + # str() should now include BOTH text AND structured output in JSON format message_string = str(result) # Output should be valid JSON parsed = json.loads(message_string) diff --git a/tests/strands/agent/test_agent_result_str.py b/tests/strands/agent/test_agent_result_str.py index 123b4b447..17a0a864e 100644 --- a/tests/strands/agent/test_agent_result_str.py +++ b/tests/strands/agent/test_agent_result_str.py @@ -1,4 +1,4 @@ -"""Tests for AgentResult.__str__ method with Option 1 behavior. +"""Tests for AgentResult.__str__ method including structured output. This module tests that __str__ properly includes both text and structured output when both exist (fix for issue #1461). @@ -25,7 +25,7 @@ class SampleOutput(BaseModel): class TestAgentResultStrOption1: - """Tests for Option 1 behavior: __str__ includes both text and structured output in JSON format.""" + """Tests for __str__ includes both text and structured output in JSON format.""" def test_str_text_only(self): """Test __str__ with only text content.""" @@ -52,7 +52,7 @@ def test_str_structured_output_only(self): def test_str_both_text_and_structured_output(self): """Test __str__ includes BOTH text and structured output when both exist. - This is the key fix for issue #1461 - Option 1 with JSON format. + This is the key fix for issue #1461 - JSON format. Output should be JSON-parseable. """ structured = SampleOutput(name="test", value=42) From aab265bc36a23c7f6d4a7fdea26fec23dbd1ae05 Mon Sep 17 00:00:00 2001 From: strands-agent Date: Wed, 14 Jan 2026 20:06:20 +0000 Subject: [PATCH 4/8] refactor(tests): consolidate __str__ tests into test_agent_result.py Per review feedback from @afarntrog, moved all __str__ method tests from the separate test_agent_result_str.py file into the existing test_agent_result.py file to keep related tests together. --- tests/strands/agent/test_agent_result.py | 253 +++++++++++++++---- tests/strands/agent/test_agent_result_str.py | 162 ------------ 2 files changed, 208 insertions(+), 207 deletions(-) delete mode 100644 tests/strands/agent/test_agent_result_str.py diff --git a/tests/strands/agent/test_agent_result.py b/tests/strands/agent/test_agent_result.py index 44ff24bce..7bfec1cf2 100644 --- a/tests/strands/agent/test_agent_result.py +++ b/tests/strands/agent/test_agent_result.py @@ -39,6 +39,46 @@ def empty_message(): return {"role": "assistant", "content": []} +@pytest.fixture +def citations_message(): + """Message with citationsContent block.""" + return { + "role": "assistant", + "content": [ + { + "citationsContent": { + "citations": [ + { + "title": "Source Document", + "location": {"document": {"pageNumber": 1}}, + "sourceContent": [{"text": "source text"}], + } + ], + "content": [{"text": "This is cited text from the document."}], + } + } + ], + } + + +@pytest.fixture +def mixed_text_and_citations_message(): + """Message with both plain text and citationsContent blocks.""" + return { + "role": "assistant", + "content": [ + {"text": "Introduction paragraph"}, + { + "citationsContent": { + "citations": [{"title": "Doc", "location": {}, "sourceContent": []}], + "content": [{"text": "Cited content here."}], + } + }, + {"text": "Conclusion paragraph"}, + ], + } + + def test__init__(mock_metrics, simple_message: Message): """Test that AgentResult can be properly initialized with all required fields.""" stop_reason: StopReason = "end_turn" @@ -100,6 +140,24 @@ def test__str__non_dict_content(mock_metrics): assert message_string == "Valid text\nMore valid text\n" +def test__str__with_citations_content(mock_metrics, citations_message: Message): + """Test that str() extracts text from citationsContent blocks.""" + result = AgentResult(stop_reason="end_turn", message=citations_message, metrics=mock_metrics, state={}) + + message_string = str(result) + assert message_string == "This is cited text from the document.\n" + + +def test__str__mixed_text_and_citations_content(mock_metrics, mixed_text_and_citations_message: Message): + """Test that str() works with both plain text and citationsContent blocks.""" + result = AgentResult( + stop_reason="end_turn", message=mixed_text_and_citations_message, metrics=mock_metrics, state={} + ) + + message_string = str(result) + assert message_string == "Introduction paragraph\nCited content here.\nConclusion paragraph\n" + + def test_to_dict(mock_metrics, simple_message: Message): """Test that to_dict serializes AgentResult correctly.""" result = AgentResult(stop_reason="end_turn", message=simple_message, metrics=mock_metrics, state={"key": "value"}) @@ -201,7 +259,7 @@ def test__str__with_structured_output(mock_metrics, simple_message: Message): message_string = str(result) # Output should be valid JSON parsed = json.loads(message_string) - assert parsed["text"] == "Hello world!" + assert parsed["text"] == "Hello world!\n" assert parsed["structured_output"]["name"] == "test" assert parsed["structured_output"]["value"] == 42 @@ -230,59 +288,164 @@ def test__str__empty_message_with_structured_output(mock_metrics, empty_message: assert "optional" in message_string -@pytest.fixture -def citations_message(): - """Message with citationsContent block.""" - return { - "role": "assistant", - "content": [ - { - "citationsContent": { - "citations": [ - { - "title": "Source Document", - "location": {"document": {"pageNumber": 1}}, - "sourceContent": [{"text": "source text"}], - } - ], - "content": [{"text": "This is cited text from the document."}], - } - } - ], - } +def test__str__structured_output_only(): + """Test __str__ with only structured output (no text).""" + structured = StructuredOutputModel(name="test", value=42) + result = AgentResult( + stop_reason="end_turn", + message={"role": "assistant", "content": []}, + metrics=EventLoopMetrics(), + state={}, + structured_output=structured, + ) + # Should return just the structured output JSON + output = str(result) + parsed = json.loads(output) + assert parsed["name"] == "test" + assert parsed["value"] == 42 -@pytest.fixture -def mixed_text_and_citations_message(): - """Message with both plain text and citationsContent blocks.""" - return { - "role": "assistant", - "content": [ - {"text": "Introduction paragraph"}, - { - "citationsContent": { - "citations": [{"title": "Doc", "location": {}, "sourceContent": []}], - "content": [{"text": "Cited content here."}], - } - }, - {"text": "Conclusion paragraph"}, - ], - } +def test__str__both_text_and_structured_output(): + """Test __str__ includes BOTH text and structured output when both exist. + Output should be JSON-parseable with text and structured_output fields. + """ + structured = StructuredOutputModel(name="test", value=42) + result = AgentResult( + stop_reason="end_turn", + message={"role": "assistant", "content": [{"text": "Here is the analysis"}]}, + metrics=EventLoopMetrics(), + state={}, + structured_output=structured, + ) + output = str(result) + # Output should be valid JSON + parsed = json.loads(output) + assert parsed["text"] == "Here is the analysis\n" + assert parsed["structured_output"]["name"] == "test" + assert parsed["structured_output"]["value"] == 42 -def test__str__with_citations_content(mock_metrics, citations_message: Message): - """Test that str() extracts text from citationsContent blocks.""" - result = AgentResult(stop_reason="end_turn", message=citations_message, metrics=mock_metrics, state={}) + +def test__str__multiple_text_blocks_with_structured_output(): + """Test __str__ with multiple text blocks and structured output.""" + structured = StructuredOutputModel(name="multi", value=100) + result = AgentResult( + stop_reason="end_turn", + message={ + "role": "assistant", + "content": [ + {"text": "First paragraph."}, + {"text": "Second paragraph."}, + ], + }, + metrics=EventLoopMetrics(), + state={}, + structured_output=structured, + ) + output = str(result) + # Output should be valid JSON + parsed = json.loads(output) + assert "First paragraph." in parsed["text"] + assert "Second paragraph." in parsed["text"] + assert parsed["structured_output"]["name"] == "multi" + assert parsed["structured_output"]["value"] == 100 + + +def test__str__non_text_content_only(): + """Test __str__ with only non-text content (e.g., toolUse).""" + result = AgentResult( + stop_reason="tool_use", + message={ + "role": "assistant", + "content": [{"toolUse": {"toolUseId": "123", "name": "test_tool", "input": {}}}], + }, + metrics=EventLoopMetrics(), + state={}, + ) + assert str(result) == "" + + +def test__str__mixed_content_with_structured_output(): + """Test __str__ with mixed content (text + toolUse) and structured output.""" + structured = StructuredOutputModel(name="mixed", value=50) + result = AgentResult( + stop_reason="end_turn", + message={ + "role": "assistant", + "content": [ + {"text": "Processing complete."}, + {"toolUse": {"toolUseId": "456", "name": "helper", "input": {}}}, + ], + }, + metrics=EventLoopMetrics(), + state={}, + structured_output=structured, + ) + output = str(result) + # Output should be valid JSON + parsed = json.loads(output) + assert parsed["text"] == "Processing complete.\n" + assert parsed["structured_output"]["name"] == "mixed" + assert parsed["structured_output"]["value"] == 50 + # toolUse should not appear in the text + assert "toolUse" not in parsed["text"] + assert "helper" not in parsed["text"] + + +def test__str__json_parseable(): + """Test that output with both text and structured output is JSON-parseable.""" + structured = StructuredOutputModel(name="parseable", value=99) + result = AgentResult( + stop_reason="end_turn", + message={"role": "assistant", "content": [{"text": "Result text"}]}, + metrics=EventLoopMetrics(), + state={}, + structured_output=structured, + ) + output = str(result) + # Should be valid JSON that can be parsed + parsed = json.loads(output) + assert "text" in parsed + assert "structured_output" in parsed + assert parsed["text"] == "Result text\n" + assert parsed["structured_output"] == {"name": "parseable", "value": 99, "optional_field": None} + + +def test__str__citations_with_structured_output(mock_metrics, citations_message: Message): + """Test that str() includes BOTH citationsContent text and structured_output.""" + structured_output = StructuredOutputModel(name="cited", value=77) + + result = AgentResult( + stop_reason="end_turn", + message=citations_message, + metrics=mock_metrics, + state={}, + structured_output=structured_output, + ) message_string = str(result) - assert message_string == "This is cited text from the document.\n" + # Output should be valid JSON with both text and structured output + parsed = json.loads(message_string) + assert parsed["text"] == "This is cited text from the document.\n" + assert parsed["structured_output"]["name"] == "cited" + assert parsed["structured_output"]["value"] == 77 -def test__str__mixed_text_and_citations_content(mock_metrics, mixed_text_and_citations_message: Message): - """Test that str() works with both plain text and citationsContent blocks.""" +def test__str__mixed_text_citations_with_structured_output(mock_metrics, mixed_text_and_citations_message: Message): + """Test that str() handles plain text, citations, and structured output together.""" + structured_output = StructuredOutputModel(name="complex", value=999) + result = AgentResult( - stop_reason="end_turn", message=mixed_text_and_citations_message, metrics=mock_metrics, state={} + stop_reason="end_turn", + message=mixed_text_and_citations_message, + metrics=mock_metrics, + state={}, + structured_output=structured_output, ) message_string = str(result) - assert message_string == "Introduction paragraph\nCited content here.\nConclusion paragraph\n" + # Output should be valid JSON + parsed = json.loads(message_string) + assert parsed["text"] == "Introduction paragraph\nCited content here.\nConclusion paragraph\n" + assert parsed["structured_output"]["name"] == "complex" + assert parsed["structured_output"]["value"] == 999 diff --git a/tests/strands/agent/test_agent_result_str.py b/tests/strands/agent/test_agent_result_str.py deleted file mode 100644 index 17a0a864e..000000000 --- a/tests/strands/agent/test_agent_result_str.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Tests for AgentResult.__str__ method including structured output. - -This module tests that __str__ properly includes both text and structured output -when both exist (fix for issue #1461). - -The output format when both text and structured output exist is JSON: -{"text": "...", "structured_output": {...}} - -This allows users to parse the output programmatically. -""" - -import json - -from pydantic import BaseModel - -from strands.agent.agent_result import AgentResult -from strands.telemetry.metrics import EventLoopMetrics - - -class SampleOutput(BaseModel): - """Sample structured output model for testing.""" - - name: str - value: int - - -class TestAgentResultStrOption1: - """Tests for __str__ includes both text and structured output in JSON format.""" - - def test_str_text_only(self): - """Test __str__ with only text content.""" - result = AgentResult( - stop_reason="end_turn", - message={"role": "assistant", "content": [{"text": "Hello world"}]}, - metrics=EventLoopMetrics(), - state={}, - ) - assert str(result) == "Hello world\n" - - def test_str_structured_output_only(self): - """Test __str__ with only structured output (no text).""" - structured = SampleOutput(name="test", value=42) - result = AgentResult( - stop_reason="end_turn", - message={"role": "assistant", "content": []}, - metrics=EventLoopMetrics(), - state={}, - structured_output=structured, - ) - assert str(result) == '{"name":"test","value":42}' - - def test_str_both_text_and_structured_output(self): - """Test __str__ includes BOTH text and structured output when both exist. - - This is the key fix for issue #1461 - JSON format. - Output should be JSON-parseable. - """ - structured = SampleOutput(name="test", value=42) - result = AgentResult( - stop_reason="end_turn", - message={"role": "assistant", "content": [{"text": "Here is the analysis"}]}, - metrics=EventLoopMetrics(), - state={}, - structured_output=structured, - ) - output = str(result) - # Output should be valid JSON - parsed = json.loads(output) - assert parsed["text"] == "Here is the analysis" - assert parsed["structured_output"]["name"] == "test" - assert parsed["structured_output"]["value"] == 42 - - def test_str_multiple_text_blocks_with_structured_output(self): - """Test __str__ with multiple text blocks and structured output.""" - structured = SampleOutput(name="multi", value=100) - result = AgentResult( - stop_reason="end_turn", - message={ - "role": "assistant", - "content": [ - {"text": "First paragraph."}, - {"text": "Second paragraph."}, - ], - }, - metrics=EventLoopMetrics(), - state={}, - structured_output=structured, - ) - output = str(result) - # Output should be valid JSON - parsed = json.loads(output) - assert "First paragraph." in parsed["text"] - assert "Second paragraph." in parsed["text"] - assert parsed["structured_output"]["name"] == "multi" - assert parsed["structured_output"]["value"] == 100 - - def test_str_empty_message_no_structured_output(self): - """Test __str__ with empty message and no structured output.""" - result = AgentResult( - stop_reason="end_turn", - message={"role": "assistant", "content": []}, - metrics=EventLoopMetrics(), - state={}, - ) - assert str(result) == "" - - def test_str_non_text_content_only(self): - """Test __str__ with only non-text content (e.g., toolUse).""" - result = AgentResult( - stop_reason="tool_use", - message={ - "role": "assistant", - "content": [{"toolUse": {"toolUseId": "123", "name": "test_tool", "input": {}}}], - }, - metrics=EventLoopMetrics(), - state={}, - ) - assert str(result) == "" - - def test_str_mixed_content_with_structured_output(self): - """Test __str__ with mixed content (text + toolUse) and structured output.""" - structured = SampleOutput(name="mixed", value=50) - result = AgentResult( - stop_reason="end_turn", - message={ - "role": "assistant", - "content": [ - {"text": "Processing complete."}, - {"toolUse": {"toolUseId": "456", "name": "helper", "input": {}}}, - ], - }, - metrics=EventLoopMetrics(), - state={}, - structured_output=structured, - ) - output = str(result) - # Output should be valid JSON - parsed = json.loads(output) - assert parsed["text"] == "Processing complete." - assert parsed["structured_output"]["name"] == "mixed" - assert parsed["structured_output"]["value"] == 50 - # toolUse should not appear in the text - assert "toolUse" not in parsed["text"] - assert "helper" not in parsed["text"] - - def test_str_json_parseable(self): - """Test that output with both text and structured output is JSON-parseable.""" - structured = SampleOutput(name="parseable", value=99) - result = AgentResult( - stop_reason="end_turn", - message={"role": "assistant", "content": [{"text": "Result text"}]}, - metrics=EventLoopMetrics(), - state={}, - structured_output=structured, - ) - output = str(result) - # Should be valid JSON that can be parsed - parsed = json.loads(output) - assert "text" in parsed - assert "structured_output" in parsed - assert parsed["text"] == "Result text" - assert parsed["structured_output"] == {"name": "parseable", "value": 99} From 42daadaea30794a9a31ae4c76c93542cef4b2e89 Mon Sep 17 00:00:00 2001 From: strands-agent Date: Thu, 15 Jan 2026 01:00:09 +0000 Subject: [PATCH 5/8] fix: preserve text content without modification Addresses review feedback from @cagataycali: - Removed .rstrip() that was modifying LLM output - Text content is now preserved exactly as produced The previous implementation used .rstrip('\n') which could strip intentional trailing newlines from the LLM output. This fix ensures the original text is preserved without modification. --- src/strands/agent/agent_result.py | 17 +++++++++++------ tests/strands/agent/test_agent_result.py | 17 +++++++---------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/strands/agent/agent_result.py b/src/strands/agent/agent_result.py index 2121cc694..1ae9cdec2 100644 --- a/src/strands/agent/agent_result.py +++ b/src/strands/agent/agent_result.py @@ -54,25 +54,30 @@ def __str__(self) -> str: """ content_array = self.message.get("content", []) - result = "" + # Collect all text content without modification + text_parts = [] for item in content_array: if isinstance(item, dict): if "text" in item: - result += item.get("text", "") + "\n" + text_parts.append(item.get("text", "")) elif "citationsContent" in item: citations_block = item["citationsContent"] if "content" in citations_block: for content in citations_block["content"]: if isinstance(content, dict) and "text" in content: - result += content.get("text", "") + "\n" + text_parts.append(content.get("text", "")) + + # Join text parts with newline, preserving original content + result = "\n".join(text_parts) + "\n" if text_parts else "" # Always include structured output when present if self.structured_output: structured_data = self.structured_output.model_dump() - if result: + if text_parts: # Both text and structured output exist - return JSON-parseable format - # Preserve text as-is (don't strip) - combined = {"text": result, "structured_output": structured_data} + # Join text parts without adding extra newlines + text_content = "\n".join(text_parts) + combined = {"text": text_content, "structured_output": structured_data} return json.dumps(combined) else: # Only structured output exists - return just the structured output JSON diff --git a/tests/strands/agent/test_agent_result.py b/tests/strands/agent/test_agent_result.py index 7bfec1cf2..70303244a 100644 --- a/tests/strands/agent/test_agent_result.py +++ b/tests/strands/agent/test_agent_result.py @@ -259,7 +259,7 @@ def test__str__with_structured_output(mock_metrics, simple_message: Message): message_string = str(result) # Output should be valid JSON parsed = json.loads(message_string) - assert parsed["text"] == "Hello world!\n" + assert parsed["text"] == "Hello world!" assert parsed["structured_output"]["name"] == "test" assert parsed["structured_output"]["value"] == 42 @@ -299,10 +299,7 @@ def test__str__structured_output_only(): structured_output=structured, ) # Should return just the structured output JSON - output = str(result) - parsed = json.loads(output) - assert parsed["name"] == "test" - assert parsed["value"] == 42 + assert str(result) == '{"name":"test","value":42,"optional_field":null}' def test__str__both_text_and_structured_output(): @@ -321,7 +318,7 @@ def test__str__both_text_and_structured_output(): output = str(result) # Output should be valid JSON parsed = json.loads(output) - assert parsed["text"] == "Here is the analysis\n" + assert parsed["text"] == "Here is the analysis" assert parsed["structured_output"]["name"] == "test" assert parsed["structured_output"]["value"] == 42 @@ -384,7 +381,7 @@ def test__str__mixed_content_with_structured_output(): output = str(result) # Output should be valid JSON parsed = json.loads(output) - assert parsed["text"] == "Processing complete.\n" + assert parsed["text"] == "Processing complete." assert parsed["structured_output"]["name"] == "mixed" assert parsed["structured_output"]["value"] == 50 # toolUse should not appear in the text @@ -407,7 +404,7 @@ def test__str__json_parseable(): parsed = json.loads(output) assert "text" in parsed assert "structured_output" in parsed - assert parsed["text"] == "Result text\n" + assert parsed["text"] == "Result text" assert parsed["structured_output"] == {"name": "parseable", "value": 99, "optional_field": None} @@ -426,7 +423,7 @@ def test__str__citations_with_structured_output(mock_metrics, citations_message: message_string = str(result) # Output should be valid JSON with both text and structured output parsed = json.loads(message_string) - assert parsed["text"] == "This is cited text from the document.\n" + assert parsed["text"] == "This is cited text from the document." assert parsed["structured_output"]["name"] == "cited" assert parsed["structured_output"]["value"] == 77 @@ -446,6 +443,6 @@ def test__str__mixed_text_citations_with_structured_output(mock_metrics, mixed_t message_string = str(result) # Output should be valid JSON parsed = json.loads(message_string) - assert parsed["text"] == "Introduction paragraph\nCited content here.\nConclusion paragraph\n" + assert parsed["text"] == "Introduction paragraph\nCited content here.\nConclusion paragraph" assert parsed["structured_output"]["name"] == "complex" assert parsed["structured_output"]["value"] == 999 From 8e82e27f4141986548e439561f27d726d63c0d1f Mon Sep 17 00:00:00 2001 From: strands-agent <217235299+strands-agent@users.noreply.github.com> Date: Thu, 15 Jan 2026 20:18:20 +0000 Subject: [PATCH 6/8] chore: apply hatch formatter and linter fixes --- src/strands/agent/agent_result.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/strands/agent/agent_result.py b/src/strands/agent/agent_result.py index 1ae9cdec2..b2dd6edd5 100644 --- a/src/strands/agent/agent_result.py +++ b/src/strands/agent/agent_result.py @@ -41,7 +41,7 @@ def __str__(self) -> str: This method extracts and concatenates all text content from the final message, including text from both "text" blocks and "citationsContent" blocks. - + When both text and structured output exist, the output is JSON-formatted so users can parse it programmatically: {"text": "...", "structured_output": {...}} From 85ace40f76665b91e9177d2a62804ab649a22b7a Mon Sep 17 00:00:00 2001 From: Aaron Farntrog Date: Wed, 21 Jan 2026 09:49:25 -0500 Subject: [PATCH 7/8] lint --- tests/strands/tools/test_decorator_pep563.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/strands/tools/test_decorator_pep563.py b/tests/strands/tools/test_decorator_pep563.py index 07ec8f2ba..44d9a626a 100644 --- a/tests/strands/tools/test_decorator_pep563.py +++ b/tests/strands/tools/test_decorator_pep563.py @@ -10,10 +10,10 @@ from __future__ import annotations -from typing import Any +from typing import Any, Literal import pytest -from typing_extensions import Literal, TypedDict +from typing_extensions import TypedDict from strands import tool From 46000370f7bf127d6c94e0da3a6f953b9891cf38 Mon Sep 17 00:00:00 2001 From: Aaron Farntrog Date: Thu, 22 Jan 2026 10:00:51 -0500 Subject: [PATCH 8/8] refactor: simplify AgentResult __str__ to concatenate structured output Change the __str__ method to append structured output JSON directly to text instead of wrapping both in a JSON object with "text" and "structured_output" keys. This provides a simpler, more predictable output format. --- src/strands/agent/agent_result.py | 21 +------ tests/strands/agent/test_agent_result.py | 72 ++++-------------------- 2 files changed, 14 insertions(+), 79 deletions(-) diff --git a/src/strands/agent/agent_result.py b/src/strands/agent/agent_result.py index b2dd6edd5..b406944da 100644 --- a/src/strands/agent/agent_result.py +++ b/src/strands/agent/agent_result.py @@ -3,7 +3,6 @@ This module defines the AgentResult class which encapsulates the complete response from an agent's processing cycle. """ -import json from collections.abc import Sequence from dataclasses import dataclass from typing import Any, cast @@ -42,12 +41,7 @@ def __str__(self) -> str: This method extracts and concatenates all text content from the final message, including text from both "text" blocks and "citationsContent" blocks. - When both text and structured output exist, the output is JSON-formatted so users - can parse it programmatically: - {"text": "...", "structured_output": {...}} - - When only text exists, returns the raw text. - When only structured output exists, returns the JSON of the structured output. + When structured output exists, its JSON representation is appended to the text. Returns: The agent's last message as a string, including any structured output. @@ -70,18 +64,9 @@ def __str__(self) -> str: # Join text parts with newline, preserving original content result = "\n".join(text_parts) + "\n" if text_parts else "" - # Always include structured output when present + # Append structured output JSON when present if self.structured_output: - structured_data = self.structured_output.model_dump() - if text_parts: - # Both text and structured output exist - return JSON-parseable format - # Join text parts without adding extra newlines - text_content = "\n".join(text_parts) - combined = {"text": text_content, "structured_output": structured_data} - return json.dumps(combined) - else: - # Only structured output exists - return just the structured output JSON - return self.structured_output.model_dump_json() + result += self.structured_output.model_dump_json() return result diff --git a/tests/strands/agent/test_agent_result.py b/tests/strands/agent/test_agent_result.py index 70303244a..364acf13c 100644 --- a/tests/strands/agent/test_agent_result.py +++ b/tests/strands/agent/test_agent_result.py @@ -1,4 +1,3 @@ -import json import unittest.mock from typing import cast @@ -244,7 +243,7 @@ def test__init__structured_output_defaults_to_none(mock_metrics, simple_message: def test__str__with_structured_output(mock_metrics, simple_message: Message): - """Test that str() includes BOTH text and structured_output in JSON format.""" + """Test that str() concatenates text and structured_output.""" structured_output = StructuredOutputModel(name="test", value=42) result = AgentResult( @@ -255,13 +254,9 @@ def test__str__with_structured_output(mock_metrics, simple_message: Message): structured_output=structured_output, ) - # str() should now include BOTH text AND structured output in JSON format + # str() should concatenate text and structured output message_string = str(result) - # Output should be valid JSON - parsed = json.loads(message_string) - assert parsed["text"] == "Hello world!" - assert parsed["structured_output"]["name"] == "test" - assert parsed["structured_output"]["value"] == 42 + assert message_string == 'Hello world!\n{"name":"test","value":42,"optional_field":null}' def test__str__empty_message_with_structured_output(mock_metrics, empty_message: Message): @@ -303,10 +298,7 @@ def test__str__structured_output_only(): def test__str__both_text_and_structured_output(): - """Test __str__ includes BOTH text and structured output when both exist. - - Output should be JSON-parseable with text and structured_output fields. - """ + """Test __str__ concatenates text and structured output when both exist.""" structured = StructuredOutputModel(name="test", value=42) result = AgentResult( stop_reason="end_turn", @@ -316,11 +308,7 @@ def test__str__both_text_and_structured_output(): structured_output=structured, ) output = str(result) - # Output should be valid JSON - parsed = json.loads(output) - assert parsed["text"] == "Here is the analysis" - assert parsed["structured_output"]["name"] == "test" - assert parsed["structured_output"]["value"] == 42 + assert output == 'Here is the analysis\n{"name":"test","value":42,"optional_field":null}' def test__str__multiple_text_blocks_with_structured_output(): @@ -340,12 +328,7 @@ def test__str__multiple_text_blocks_with_structured_output(): structured_output=structured, ) output = str(result) - # Output should be valid JSON - parsed = json.loads(output) - assert "First paragraph." in parsed["text"] - assert "Second paragraph." in parsed["text"] - assert parsed["structured_output"]["name"] == "multi" - assert parsed["structured_output"]["value"] == 100 + assert output == 'First paragraph.\nSecond paragraph.\n{"name":"multi","value":100,"optional_field":null}' def test__str__non_text_content_only(): @@ -379,37 +362,11 @@ def test__str__mixed_content_with_structured_output(): structured_output=structured, ) output = str(result) - # Output should be valid JSON - parsed = json.loads(output) - assert parsed["text"] == "Processing complete." - assert parsed["structured_output"]["name"] == "mixed" - assert parsed["structured_output"]["value"] == 50 - # toolUse should not appear in the text - assert "toolUse" not in parsed["text"] - assert "helper" not in parsed["text"] - - -def test__str__json_parseable(): - """Test that output with both text and structured output is JSON-parseable.""" - structured = StructuredOutputModel(name="parseable", value=99) - result = AgentResult( - stop_reason="end_turn", - message={"role": "assistant", "content": [{"text": "Result text"}]}, - metrics=EventLoopMetrics(), - state={}, - structured_output=structured, - ) - output = str(result) - # Should be valid JSON that can be parsed - parsed = json.loads(output) - assert "text" in parsed - assert "structured_output" in parsed - assert parsed["text"] == "Result text" - assert parsed["structured_output"] == {"name": "parseable", "value": 99, "optional_field": None} + assert output == 'Processing complete.\n{"name":"mixed","value":50,"optional_field":null}' def test__str__citations_with_structured_output(mock_metrics, citations_message: Message): - """Test that str() includes BOTH citationsContent text and structured_output.""" + """Test that str() concatenates citationsContent text and structured_output.""" structured_output = StructuredOutputModel(name="cited", value=77) result = AgentResult( @@ -421,11 +378,7 @@ def test__str__citations_with_structured_output(mock_metrics, citations_message: ) message_string = str(result) - # Output should be valid JSON with both text and structured output - parsed = json.loads(message_string) - assert parsed["text"] == "This is cited text from the document." - assert parsed["structured_output"]["name"] == "cited" - assert parsed["structured_output"]["value"] == 77 + assert message_string == 'This is cited text from the document.\n{"name":"cited","value":77,"optional_field":null}' def test__str__mixed_text_citations_with_structured_output(mock_metrics, mixed_text_and_citations_message: Message): @@ -441,8 +394,5 @@ def test__str__mixed_text_citations_with_structured_output(mock_metrics, mixed_t ) message_string = str(result) - # Output should be valid JSON - parsed = json.loads(message_string) - assert parsed["text"] == "Introduction paragraph\nCited content here.\nConclusion paragraph" - assert parsed["structured_output"]["name"] == "complex" - assert parsed["structured_output"]["value"] == 999 + expected = 'Introduction paragraph\nCited content here.\nConclusion paragraph\n{"name":"complex","value":999,"optional_field":null}' + assert message_string == expected