Skip to content

Commit 7a0d12f

Browse files
author
Lucas Messenger
committed
feat(bedrock): add native structured output support via outputConfig.textFormat
Add opt-in native structured output mode for BedrockModel that uses Bedrock's outputConfig.textFormat API for schema-constrained responses, replacing the tool-based workaround when enabled. Model-level: - Add `structured_output_mode` config ("tool" | "native", defaults to "tool") - Add `convert_pydantic_to_json_schema()` utility with recursive `additionalProperties: false` injection - Thread `output_config` through stream() -> _stream() -> _format_request() - Native mode parses JSON text response instead of extracting tool use args Agent-level: - When native_mode is enabled, the agent loop runs normally with tools and thinking. On end_turn, calls model.structured_output() for final formatting instead of forcing tool use (which disables thinking). Closes #1652
1 parent 287c5b6 commit 7a0d12f

9 files changed

Lines changed: 404 additions & 42 deletions

File tree

src/strands/agent/agent.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -886,9 +886,14 @@ async def _run_loop(
886886

887887
await self._append_messages(*current_messages)
888888

889+
# Check if the model supports native structured output
890+
model_config = self.model.get_config()
891+
native_mode = isinstance(model_config, dict) and model_config.get("structured_output_mode") == "native"
892+
889893
structured_output_context = StructuredOutputContext(
890894
structured_output_model or self._default_structured_output_model,
891895
structured_output_prompt=structured_output_prompt or self._structured_output_prompt,
896+
native_mode=native_mode,
892897
)
893898

894899
# Execute the event loop cycle with retry logic for context limits
@@ -950,7 +955,7 @@ async def _execute_event_loop_cycle(
950955
# Add `Agent` to invocation_state to keep backwards-compatibility
951956
invocation_state["agent"] = self
952957

953-
if structured_output_context:
958+
if structured_output_context and not structured_output_context.native_mode:
954959
structured_output_context.register_tool(self.tool_registry)
955960

956961
try:

src/strands/event_loop/event_loop.py

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -201,25 +201,57 @@ async def event_loop_cycle(
201201
# End the cycle and return results
202202
agent.event_loop_metrics.end_cycle(cycle_start_time, cycle_trace, attributes)
203203

204-
# Force structured output tool call if LLM didn't use it automatically
204+
# Handle structured output when model returns end_turn
205205
if structured_output_context.is_enabled and stop_reason == "end_turn":
206-
if structured_output_context.force_attempted:
206+
if structured_output_context.native_mode:
207+
# Native mode: use model's native structured output for final formatting.
208+
# The agent loop ran normally with tools and thinking; only this final
209+
# step uses native structured output via model.structured_output().
210+
logger.debug("using native structured output for final formatting")
211+
native_result = None
212+
async for event in agent.model.structured_output(
213+
structured_output_context.structured_output_model,
214+
agent.messages,
215+
system_prompt=agent.system_prompt,
216+
):
217+
if "output" in event:
218+
native_result = event["output"]
219+
220+
if native_result is not None:
221+
yield StructuredOutputEvent(structured_output=native_result)
222+
tracer.end_event_loop_cycle_span(cycle_span, message)
223+
yield EventLoopStopEvent(
224+
stop_reason,
225+
message,
226+
agent.event_loop_metrics,
227+
invocation_state["request_state"],
228+
structured_output=native_result,
229+
)
230+
return
207231
raise StructuredOutputException(
208-
"The model failed to invoke the structured output tool even after it was forced."
232+
"Native structured output mode: model did not return structured output."
233+
)
234+
else:
235+
# Tool mode: force the model to call the structured output tool
236+
if structured_output_context.force_attempted:
237+
raise StructuredOutputException(
238+
"The model failed to invoke the structured output tool even after it was forced."
239+
)
240+
structured_output_context.set_forced_mode()
241+
logger.debug("Forcing structured output tool")
242+
await agent._append_messages(
243+
{"role": "user", "content": [{"text": structured_output_context.structured_output_prompt}]}
209244
)
210-
structured_output_context.set_forced_mode()
211-
logger.debug("Forcing structured output tool")
212-
await agent._append_messages(
213-
{"role": "user", "content": [{"text": structured_output_context.structured_output_prompt}]}
214-
)
215245

216-
tracer.end_event_loop_cycle_span(cycle_span, message)
217-
events = recurse_event_loop(
218-
agent=agent, invocation_state=invocation_state, structured_output_context=structured_output_context
219-
)
220-
async for typed_event in events:
221-
yield typed_event
222-
return
246+
tracer.end_event_loop_cycle_span(cycle_span, message)
247+
events = recurse_event_loop(
248+
agent=agent,
249+
invocation_state=invocation_state,
250+
structured_output_context=structured_output_context,
251+
)
252+
async for typed_event in events:
253+
yield typed_event
254+
return
223255

224256
tracer.end_event_loop_cycle_span(cycle_span, message)
225257
yield EventLoopStopEvent(stop_reason, message, agent.event_loop_metrics, invocation_state["request_state"])

src/strands/models/bedrock.py

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
from .._exception_notes import add_exception_note
2323
from ..event_loop import streaming
24-
from ..tools import convert_pydantic_to_tool_spec
24+
from ..tools import convert_pydantic_to_json_schema, convert_pydantic_to_tool_spec
2525
from ..tools._tool_helpers import noop_tool
2626
from ..types.content import ContentBlock, Messages, SystemContentBlock
2727
from ..types.exceptions import (
@@ -98,6 +98,9 @@ class BedrockConfig(TypedDict, total=False):
9898
Please check https://docs.aws.amazon.com/bedrock/latest/userguide/service-tiers-inference.html for
9999
supported service tiers, models, and regions
100100
stop_sequences: List of sequences that will stop generation when encountered
101+
structured_output_mode: Mode for structured output. "tool" (default) uses tool-based approach,
102+
"native" uses Bedrock's outputConfig.textFormat for schema-constrained responses.
103+
Native mode requires a model that supports structured output.
101104
streaming: Flag to enable/disable streaming. Defaults to True.
102105
temperature: Controls randomness in generation (higher = more random)
103106
top_p: Controls diversity via nucleus sampling (alternative to temperature)
@@ -123,6 +126,7 @@ class BedrockConfig(TypedDict, total=False):
123126
include_tool_result_status: Literal["auto"] | bool | None
124127
service_tier: str | None
125128
stop_sequences: list[str] | None
129+
structured_output_mode: Literal["tool", "native"] | None
126130
streaming: bool | None
127131
temperature: float | None
128132
top_p: float | None
@@ -218,6 +222,7 @@ def _format_request(
218222
tool_specs: list[ToolSpec] | None = None,
219223
system_prompt_content: list[SystemContentBlock] | None = None,
220224
tool_choice: ToolChoice | None = None,
225+
output_config: dict[str, Any] | None = None,
221226
) -> dict[str, Any]:
222227
"""Format a Bedrock converse stream request.
223228
@@ -226,6 +231,7 @@ def _format_request(
226231
tool_specs: List of tool specifications to make available to the model.
227232
tool_choice: Selection strategy for tool invocation.
228233
system_prompt_content: System prompt content blocks to provide context to the model.
234+
output_config: Output configuration for structured output (JSON schema).
229235
230236
Returns:
231237
A Bedrock converse stream request.
@@ -251,6 +257,20 @@ def _format_request(
251257
"messages": self._format_bedrock_messages(messages),
252258
"system": system_blocks,
253259
**({"serviceTier": {"type": self.config["service_tier"]}} if self.config.get("service_tier") else {}),
260+
**(
261+
{
262+
"outputConfig": {
263+
"textFormat": {
264+
"type": "json_schema",
265+
"structure": {
266+
"jsonSchema": output_config,
267+
},
268+
},
269+
}
270+
}
271+
if output_config
272+
else {}
273+
),
254274
**(
255275
{
256276
"toolConfig": {
@@ -747,6 +767,7 @@ async def stream(
747767
*,
748768
tool_choice: ToolChoice | None = None,
749769
system_prompt_content: list[SystemContentBlock] | None = None,
770+
output_config: dict[str, Any] | None = None,
750771
**kwargs: Any,
751772
) -> AsyncGenerator[StreamEvent, None]:
752773
"""Stream conversation with the Bedrock model.
@@ -760,6 +781,7 @@ async def stream(
760781
system_prompt: System prompt to provide context to the model.
761782
tool_choice: Selection strategy for tool invocation.
762783
system_prompt_content: System prompt content blocks to provide context to the model.
784+
output_config: Output configuration for structured output (JSON schema).
763785
**kwargs: Additional keyword arguments for future extensibility.
764786
765787
Yields:
@@ -782,7 +804,9 @@ def callback(event: StreamEvent | None = None) -> None:
782804
if system_prompt and system_prompt_content is None:
783805
system_prompt_content = [{"text": system_prompt}]
784806

785-
thread = asyncio.to_thread(self._stream, callback, messages, tool_specs, system_prompt_content, tool_choice)
807+
thread = asyncio.to_thread(
808+
self._stream, callback, messages, tool_specs, system_prompt_content, tool_choice, output_config
809+
)
786810
task = asyncio.create_task(thread)
787811

788812
while True:
@@ -801,6 +825,7 @@ def _stream(
801825
tool_specs: list[ToolSpec] | None = None,
802826
system_prompt_content: list[SystemContentBlock] | None = None,
803827
tool_choice: ToolChoice | None = None,
828+
output_config: dict[str, Any] | None = None,
804829
) -> None:
805830
"""Stream conversation with the Bedrock model.
806831
@@ -813,14 +838,15 @@ def _stream(
813838
tool_specs: List of tool specifications to make available to the model.
814839
system_prompt_content: System prompt content blocks to provide context to the model.
815840
tool_choice: Selection strategy for tool invocation.
841+
output_config: Output configuration for structured output (JSON schema).
816842
817843
Raises:
818844
ContextWindowOverflowException: If the input exceeds the model's context window.
819845
ModelThrottledException: If the model service is throttling requests.
820846
"""
821847
try:
822848
logger.debug("formatting request")
823-
request = self._format_request(messages, tool_specs, system_prompt_content, tool_choice)
849+
request = self._format_request(messages, tool_specs, system_prompt_content, tool_choice, output_config)
824850
logger.debug("request=<%s>", request)
825851

826852
logger.debug("invoking model")
@@ -1032,6 +1058,10 @@ async def structured_output(
10321058
) -> AsyncGenerator[dict[str, T | Any], None]:
10331059
"""Get structured output from the model.
10341060
1061+
Supports two modes controlled by `structured_output_mode` config:
1062+
- "tool" (default): Converts the Pydantic model to a tool spec and forces tool use.
1063+
- "native": Uses Bedrock's outputConfig.textFormat with JSON schema for guaranteed schema compliance.
1064+
10351065
Args:
10361066
output_model: The output model to use for the agent.
10371067
prompt: The prompt messages to use for the agent.
@@ -1041,6 +1071,21 @@ async def structured_output(
10411071
Yields:
10421072
Model events with the last being the structured output.
10431073
"""
1074+
if self.config.get("structured_output_mode") == "native":
1075+
async for event in self._structured_output_native(output_model, prompt, system_prompt, **kwargs):
1076+
yield event
1077+
else:
1078+
async for event in self._structured_output_tool(output_model, prompt, system_prompt, **kwargs):
1079+
yield event
1080+
1081+
async def _structured_output_tool(
1082+
self,
1083+
output_model: type[T],
1084+
prompt: Messages,
1085+
system_prompt: str | None = None,
1086+
**kwargs: Any,
1087+
) -> AsyncGenerator[dict[str, T | Any], None]:
1088+
"""Structured output using tool-based approach."""
10441089
tool_spec = convert_pydantic_to_tool_spec(output_model)
10451090

10461091
response = self.stream(
@@ -1073,6 +1118,39 @@ async def structured_output(
10731118

10741119
yield {"output": output_model(**output_response)}
10751120

1121+
async def _structured_output_native(
1122+
self,
1123+
output_model: type[T],
1124+
prompt: Messages,
1125+
system_prompt: str | None = None,
1126+
**kwargs: Any,
1127+
) -> AsyncGenerator[dict[str, T | Any], None]:
1128+
"""Structured output using Bedrock's native outputConfig.textFormat."""
1129+
output_config = convert_pydantic_to_json_schema(output_model)
1130+
1131+
response = self.stream(
1132+
messages=prompt,
1133+
system_prompt=system_prompt,
1134+
output_config=output_config,
1135+
**kwargs,
1136+
)
1137+
async for event in streaming.process_stream(response):
1138+
yield event
1139+
1140+
_, messages, _, _ = event["stop"]
1141+
1142+
content = messages["content"]
1143+
text_content: str | None = None
1144+
for block in content:
1145+
if "text" in block and block["text"].strip():
1146+
text_content = block["text"]
1147+
1148+
if text_content is None:
1149+
raise ValueError("No text content found in the Bedrock response for native structured output.")
1150+
1151+
output_response = json.loads(text_content)
1152+
yield {"output": output_model(**output_response)}
1153+
10761154
@staticmethod
10771155
def _get_default_model_with_warning(region_name: str, model_config: BedrockConfig | None = None) -> str:
10781156
"""Get the default Bedrock modelId based on region.

src/strands/tools/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"""
55

66
from .decorator import tool
7-
from .structured_output import convert_pydantic_to_tool_spec
7+
from .structured_output import convert_pydantic_to_json_schema, convert_pydantic_to_tool_spec
88
from .tool_provider import ToolProvider
99
from .tools import InvalidToolUseNameException, PythonAgentTool, normalize_schema, normalize_tool_spec
1010

@@ -14,6 +14,7 @@
1414
"InvalidToolUseNameException",
1515
"normalize_schema",
1616
"normalize_tool_spec",
17+
"convert_pydantic_to_json_schema",
1718
"convert_pydantic_to_tool_spec",
1819
"ToolProvider",
1920
]
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Structured output tools for the Strands Agents framework."""
22

33
from ._structured_output_context import DEFAULT_STRUCTURED_OUTPUT_PROMPT
4-
from .structured_output_utils import convert_pydantic_to_tool_spec
4+
from .structured_output_utils import convert_pydantic_to_json_schema, convert_pydantic_to_tool_spec
55

6-
__all__ = ["convert_pydantic_to_tool_spec", "DEFAULT_STRUCTURED_OUTPUT_PROMPT"]
6+
__all__ = ["convert_pydantic_to_json_schema", "convert_pydantic_to_tool_spec", "DEFAULT_STRUCTURED_OUTPUT_PROMPT"]

src/strands/tools/structured_output/_structured_output_context.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,25 +23,30 @@ def __init__(
2323
self,
2424
structured_output_model: type[BaseModel] | None = None,
2525
structured_output_prompt: str | None = None,
26+
native_mode: bool = False,
2627
):
2728
"""Initialize a new structured output context.
2829
2930
Args:
3031
structured_output_model: Optional Pydantic model type for structured output.
3132
structured_output_prompt: Optional custom prompt message to use when forcing structured output.
3233
Defaults to "You must format the previous response as structured output."
34+
native_mode: If True, use the model's native structured output for the final formatting step
35+
instead of forcing tool use. The agent loop runs normally with tools and thinking;
36+
only the final response formatting uses native structured output.
3337
"""
3438
self.results: dict[str, BaseModel] = {}
3539
self.structured_output_model: type[BaseModel] | None = structured_output_model
3640
self.structured_output_tool: StructuredOutputTool | None = None
41+
self.native_mode: bool = native_mode
3742
self.forced_mode: bool = False
3843
self.force_attempted: bool = False
3944
self.tool_choice: ToolChoice | None = None
4045
self.stop_loop: bool = False
4146
self.expected_tool_name: str | None = None
4247
self.structured_output_prompt: str = structured_output_prompt or DEFAULT_STRUCTURED_OUTPUT_PROMPT
4348

44-
if structured_output_model:
49+
if structured_output_model and not native_mode:
4550
self.structured_output_tool = StructuredOutputTool(structured_output_model)
4651
self.expected_tool_name = self.structured_output_tool.tool_name
4752

0 commit comments

Comments
 (0)