Skip to content

Commit e355fa8

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. - 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 Closes #1652
1 parent 287c5b6 commit e355fa8

File tree

6 files changed

+319
-25
lines changed

6 files changed

+319
-25
lines changed

src/strands/models/bedrock.py

Lines changed: 82 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,40 @@ 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:
1146+
text_content = block["text"]
1147+
break
1148+
1149+
if text_content is None:
1150+
raise ValueError("No text content found in the Bedrock response for native structured output.")
1151+
1152+
output_response = json.loads(text_content)
1153+
yield {"output": output_model(**output_response)}
1154+
10761155
@staticmethod
10771156
def _get_default_model_with_warning(region_name: str, model_config: BedrockConfig | None = None) -> str:
10781157
"""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_utils.py

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tools for converting Pydantic models to Bedrock tools."""
22

3+
import json
34
from typing import Any, Union
45

56
from pydantic import BaseModel
@@ -257,48 +258,56 @@ def _process_nested_dict(d: dict[str, Any], defs: dict[str, Any]) -> dict[str, A
257258
return result
258259

259260

260-
def convert_pydantic_to_tool_spec(
261+
def _prepare_pydantic_schema(
261262
model: type[BaseModel],
262263
description: str | None = None,
263-
) -> ToolSpec:
264-
"""Converts a Pydantic model to a tool description for the Amazon Bedrock Converse API.
264+
) -> tuple[str, str, dict[str, Any]]:
265+
"""Shared pipeline for converting a Pydantic model to a flattened JSON schema.
265266
266-
Handles optional vs. required fields, resolves $refs, and uses docstrings.
267+
Resolves $refs, expands nested properties, flattens the schema, and resolves the description.
267268
268269
Args:
269-
model: The Pydantic model class to convert
270-
description: Optional description of the tool's purpose
270+
model: The Pydantic model class to convert.
271+
description: Optional description override.
271272
272273
Returns:
273-
ToolSpec: Dict containing the Bedrock tool specification
274+
Tuple of (name, description, flattened_schema).
274275
"""
275276
name = model.__name__
276277

277-
# Get the JSON schema
278278
input_schema = model.model_json_schema()
279279

280-
# Get model docstring for description if not provided
281280
model_description = description
282281
if not model_description and model.__doc__:
283282
model_description = model.__doc__.strip()
284283

285-
# Process all referenced models to ensure proper docstrings
286-
# This step is important for gathering descriptions from referenced models
287284
_process_referenced_models(input_schema, model)
288-
289-
# Now, let's fully expand the nested models with all their properties
290285
_expand_nested_properties(input_schema, model)
291286

292-
# Flatten the schema
293-
flattened_schema = _flatten_schema(input_schema)
287+
return name, model_description or "", _flatten_schema(input_schema)
288+
289+
290+
def convert_pydantic_to_tool_spec(
291+
model: type[BaseModel],
292+
description: str | None = None,
293+
) -> ToolSpec:
294+
"""Converts a Pydantic model to a tool description for the Amazon Bedrock Converse API.
295+
296+
Handles optional vs. required fields, resolves $refs, and uses docstrings.
297+
298+
Args:
299+
model: The Pydantic model class to convert
300+
description: Optional description of the tool's purpose
294301
295-
final_schema = flattened_schema
302+
Returns:
303+
ToolSpec: Dict containing the Bedrock tool specification
304+
"""
305+
name, model_description, flattened_schema = _prepare_pydantic_schema(model, description)
296306

297-
# Construct the tool specification
298307
return ToolSpec(
299308
name=name,
300309
description=model_description or f"{name} structured output tool",
301-
inputSchema={"json": final_schema},
310+
inputSchema={"json": flattened_schema},
302311
)
303312

304313

@@ -402,3 +411,52 @@ def _process_properties(schema_def: dict[str, Any], model: type[BaseModel]) -> N
402411
# Add field description if available and not already set
403412
if field and field.description and not prop_info.get("description"):
404413
prop_info["description"] = field.description
414+
415+
416+
def _add_additional_properties_false(schema: dict[str, Any]) -> None:
417+
"""Recursively add additionalProperties: false to all object types in a JSON schema.
418+
419+
Bedrock's native structured output requires additionalProperties: false at every object level.
420+
Mutates the schema in place.
421+
422+
Args:
423+
schema: The JSON schema to process (modified in place).
424+
"""
425+
schema_type = schema.get("type")
426+
if schema_type == "object" or (isinstance(schema_type, list) and "object" in schema_type):
427+
schema["additionalProperties"] = False
428+
429+
if "properties" in schema:
430+
for value in schema["properties"].values():
431+
if isinstance(value, dict):
432+
_add_additional_properties_false(value)
433+
434+
if "items" in schema and isinstance(schema["items"], dict):
435+
_add_additional_properties_false(schema["items"])
436+
437+
438+
def convert_pydantic_to_json_schema(
439+
model: type[BaseModel],
440+
description: str | None = None,
441+
) -> dict[str, Any]:
442+
"""Convert a Pydantic model to a JSON schema dict for Bedrock native structured output.
443+
444+
Returns a dict with "schema" (JSON string), "name", and "description" keys,
445+
suitable for use in outputConfig.textFormat.structure.jsonSchema.
446+
447+
Args:
448+
model: The Pydantic model class to convert.
449+
description: Optional description override.
450+
451+
Returns:
452+
Dict with "schema" (JSON string), "name", and "description".
453+
"""
454+
name, model_description, flattened_schema = _prepare_pydantic_schema(model, description)
455+
456+
_add_additional_properties_false(flattened_schema)
457+
458+
return {
459+
"schema": json.dumps(flattened_schema),
460+
"name": name,
461+
"description": model_description or f"{name} structured output",
462+
}

0 commit comments

Comments
 (0)