Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
41 changes: 31 additions & 10 deletions src/strands/agent/agent_result.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When both text and structured output exist:

{"text": "Your text content here", "structured_output": {"field": "value"}}

This allows users to parse the output programmatically:

import json
result = agent("prompt", structured_output_model=MyModel)
output = str(result)
parsed = json.loads(output)
print(parsed["text"])  # The text response
print(parsed["structured_output"])  # The structured data as dict

This seems odd to me; we're using a str representation to include JSON; I think of str as human readable and this seems to go the opposite direction.

If anything, I would expect the JSON to be appended to the text at the end, not JSON-stringified. If we wanted both the JSON and the string text, why not use the AgentResult directly to get those items?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or a better question:

Why is:

This allows users to parse the output programmatically:

A goal of __str__?

If, we're attempting to fix:

When AgentResult.str is called (e.g., in graph node transitions), it only returns structured_output when there is NO text content. This causes structured data to be lost when both text and structured output exist.

I don't think JSON in str is the right answer - though perhaps I'm the minority in that opinion.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's ultimately stringified json though. The benefit I see of using json in the string is that gives users the flexibility of parsing the str if they want. I don't think it'll degrade the str output. It'll provide more flexibility

Copy link
Member

@zastrowm zastrowm Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going from:

Now that I have all the information, I can format the JSON appropriately:

to:

{"text", "Now that I have all the information, I can format the JSON appropriately", "structured_output": { "name": "Mackenzie Zastrow", favoriateCake: "Chocolate" } }

Still doesn't make sense to me. Why are we special casing that when you have structured_output, __str__ is JSON-fying the entire thing?

If you want it strongly typed/structured use AgentResult, if you want it as a string, call __str__ but I think conflating the two is odd.

Whereas this makes more sense:

Now that I have all the information, I can format the JSON appropriately:
{ "name": "Mackenzie Zastrow", favoriateCake: "Chocolate" }

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering what’s stopping us from appending structured_output right after line 1004 (though we should skip adding duplicate JSON strings when there’s no text present)?

for result in agent_results:
agent_name = getattr(result, "agent_name", "Agent")
result_text = str(result)
node_input.append(ContentBlock(text=f" - {agent_name}: {result_text}"))

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -38,29 +39,49 @@ 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,
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.
The agent's last message as a string, including any structured output.
"""
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"

if not result and self.structured_output:
result = self.structured_output.model_dump_json()
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 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()

return result

Expand Down
261 changes: 212 additions & 49 deletions tests/strands/agent/test_agent_result.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import unittest.mock
from typing import cast

Expand Down Expand Up @@ -38,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"
Expand Down Expand Up @@ -99,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"})
Expand Down Expand Up @@ -185,7 +244,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 in JSON format."""
structured_output = StructuredOutputModel(name="test", value=42)

result = AgentResult(
Expand All @@ -196,11 +255,13 @@ 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
# str() should now include BOTH text AND structured output in JSON format
message_string = str(result)
assert message_string == "Hello world!\n"
assert "test" not in message_string
assert "42" not 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):
Expand All @@ -227,59 +288,161 @@ 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
assert str(result) == '{"name":"test","value":42,"optional_field":null}'


@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"
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."
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}


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."
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"
assert parsed["structured_output"]["name"] == "complex"
assert parsed["structured_output"]["value"] == 999
4 changes: 2 additions & 2 deletions tests/strands/tools/test_decorator_pep563.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading