Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dd86a56
feat: Implement comprehensive structured output system
afarntrog Sep 29, 2025
dceb617
Add user instruction message when forcing structured output
afarntrog Sep 29, 2025
75a0ce7
add readme with model provider examples
afarntrog Sep 29, 2025
b543cec
rm output file extra
afarntrog Sep 29, 2025
099e70a
rm lock file
afarntrog Sep 29, 2025
882fcd6
update readme
afarntrog Sep 29, 2025
7f2d73e
Refactor structured output from handler to context pattern
afarntrog Sep 30, 2025
4979771
make instance variable
afarntrog Sep 30, 2025
b9f9456
Refactor structured output to use cached tool_specs property
afarntrog Oct 1, 2025
36bd507
Refactor: Rename structured_output_type to structured_output_model
afarntrog Oct 1, 2025
dba8828
Remove NativeMode and PromptMode output classes
afarntrog Oct 1, 2025
fb274ac
cleanup
afarntrog Oct 1, 2025
dcc6ac4
cleanup
afarntrog Oct 1, 2025
45dd56b
use model instead of type
afarntrog Oct 1, 2025
7ad09b2
Refactor: Remove OutputSchema abstraction, pass StructuredOutputConte…
afarntrog Oct 3, 2025
9003fdd
Update type hints to use modern union syntax
afarntrog Oct 3, 2025
7b192a1
hatch fmt --formatter
afarntrog Oct 3, 2025
89ea3c6
Refactor structured output handling and improve error reporting
afarntrog Oct 5, 2025
247e9c4
Change structured_output_context default to empty instance
afarntrog Oct 5, 2025
eeb97be
cleanup
afarntrog Oct 5, 2025
8f5ffad
cleanup
afarntrog Oct 6, 2025
a42171c
Refactor structured_output module and improve type safety
afarntrog Oct 6, 2025
cfb39f5
Make structured output context optional and update formatting
afarntrog Oct 6, 2025
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
10 changes: 9 additions & 1 deletion src/strands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,12 @@
from .tools.decorator import tool
from .types.tools import ToolContext

__all__ = ["Agent", "agent", "models", "tool", "types", "telemetry", "ToolContext"]
__all__ = [
"Agent",
"agent",
"models",
"tool",
"ToolContext",
"types",
"telemetry",
]
67 changes: 56 additions & 11 deletions src/strands/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

from opentelemetry import trace as trace_api
from pydantic import BaseModel
from typing_extensions import deprecated

from .. import _identifier
from ..event_loop.event_loop import event_loop_cycle
Expand All @@ -49,6 +50,7 @@
from ..tools.executors import ConcurrentToolExecutor
from ..tools.executors._executor import ToolExecutor
from ..tools.registry import ToolRegistry
from ..tools.structured_output.structured_output_context import StructuredOutputContext
from ..tools.watcher import ToolWatcher
from ..types._events import AgentResultEvent, InitEventLoopEvent, ModelStreamChunkEvent, TypedEvent
from ..types.agent import AgentInput
Expand Down Expand Up @@ -210,6 +212,7 @@ def __init__(
messages: Optional[Messages] = None,
tools: Optional[list[Union[str, dict[str, str], Any]]] = None,
system_prompt: Optional[str] = None,
structured_output_model: Optional[Type[BaseModel]] = None,
callback_handler: Optional[
Union[Callable[..., Any], _DefaultCallbackHandlerSentinel]
] = _DEFAULT_CALLBACK_HANDLER,
Expand Down Expand Up @@ -245,6 +248,10 @@ def __init__(
If provided, only these tools will be available. If None, all tools will be available.
system_prompt: System prompt to guide model behavior.
If None, the model will behave according to its default settings.
structured_output_model: Pydantic model type(s) for structured output.
When specified, all agent calls will attempt to return structured output of this type.
This can be overridden on the agent invocation.
Copy link
Member

Choose a reason for hiding this comment

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

Nit: I think this goes against convention for the agent class. This should either be a class attribute, or an kwargument on the invoke method. Im inclined to lean toward just the invoke kwargument.

Defaults to None (no structured output).
callback_handler: Callback for processing events as they happen during agent execution.
If not provided (using the default), a new PrintingCallbackHandler instance is created.
If explicitly set to None, null_callback_handler is used.
Expand Down Expand Up @@ -274,8 +281,8 @@ def __init__(
"""
self.model = BedrockModel() if not model else BedrockModel(model_id=model) if isinstance(model, str) else model
self.messages = messages if messages is not None else []

self.system_prompt = system_prompt
self._default_structured_output_model = structured_output_model
self.agent_id = _identifier.validate(agent_id or _DEFAULT_AGENT_ID, _identifier.Identifier.AGENT)
self.name = name or _DEFAULT_AGENT_NAME
self.description = description
Expand Down Expand Up @@ -374,7 +381,9 @@ def tool_names(self) -> list[str]:
all_tools = self.tool_registry.get_all_tools_config()
return list(all_tools.keys())

def __call__(self, prompt: AgentInput = None, **kwargs: Any) -> AgentResult:
def __call__(
Copy link
Member

Choose a reason for hiding this comment

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

Nit: pretty sure this has updated, so you will need to rebase

self, prompt: AgentInput = None, structured_output_model: Type[BaseModel] | None = None, **kwargs: Any
) -> AgentResult:
"""Process a natural language prompt through the agent's event loop.
This method implements the conversational interface with multiple input patterns:
Expand All @@ -389,6 +398,7 @@ def __call__(self, prompt: AgentInput = None, **kwargs: Any) -> AgentResult:
- list[ContentBlock]: Multi-modal content blocks
- list[Message]: Complete messages with roles
- None: Use existing conversation history
structured_output_model: Pydantic model type(s) for structured output (overrides agent default).
Copy link
Member

Choose a reason for hiding this comment

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

Is there a way to pass None if you don't want structured output? Should that be an option?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just ignore it and it'll use the default None. The user can also set structured_output_model=None as well

Copy link
Member

Choose a reason for hiding this comment

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

What I meant was:

agent = Agent(structuctured_output_model=Cat)
...
agent.invoke_async(structured_output_model=None) # to turn it off

Not a blocker though

Copy link
Member

Choose a reason for hiding this comment

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

Nit: Left a comment above, this goes against the convention of the Agent class. We used to have every class level attribute overridable in the invoke method, but this was tripping customers up. We ended up deciding on having one way to set things so its more obvious what is going on

**kwargs: Additional parameters to pass through the event loop.
Returns:
Expand All @@ -398,16 +408,19 @@ def __call__(self, prompt: AgentInput = None, **kwargs: Any) -> AgentResult:
- message: The final message from the model
- metrics: Performance metrics from the event loop
- state: The final state of the event loop
- structured_output: Parsed structured output when structured_output_model was specified
"""

def execute() -> AgentResult:
return asyncio.run(self.invoke_async(prompt, **kwargs))
return asyncio.run(self.invoke_async(prompt, structured_output_model, **kwargs))

with ThreadPoolExecutor() as executor:
future = executor.submit(execute)
return future.result()

async def invoke_async(self, prompt: AgentInput = None, **kwargs: Any) -> AgentResult:
async def invoke_async(
self, prompt: AgentInput = None, structured_output_model: Type[BaseModel] | None = None, **kwargs: Any
) -> AgentResult:
"""Process a natural language prompt through the agent's event loop.
This method implements the conversational interface with multiple input patterns:
Expand All @@ -422,6 +435,7 @@ async def invoke_async(self, prompt: AgentInput = None, **kwargs: Any) -> AgentR
- list[ContentBlock]: Multi-modal content blocks
- list[Message]: Complete messages with roles
- None: Use existing conversation history
structured_output_model: Pydantic model type(s) for structured output (overrides agent default).
**kwargs: Additional parameters to pass through the event loop.
Returns:
Expand All @@ -432,12 +446,17 @@ async def invoke_async(self, prompt: AgentInput = None, **kwargs: Any) -> AgentR
- metrics: Performance metrics from the event loop
- state: The final state of the event loop
"""
events = self.stream_async(prompt, **kwargs)
events = self.stream_async(prompt, structured_output_model=structured_output_model, **kwargs)
async for event in events:
_ = event

return cast(AgentResult, event["result"])

@deprecated(
"Agent.structured_output method is deprecated."
" You should pass in `structured_output_model` directly into the agent invocation."
" see the <LINK> for more details"
Copy link
Member

Choose a reason for hiding this comment

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

TODO - update LINK

Copy link
Member

Choose a reason for hiding this comment

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

The @deprecated annotation is only available in python >= 3.13. You should use warnings.warn instead as thats compatible with all versions of python we currently support:

warnings.warn(

)
def structured_output(self, output_model: Type[T], prompt: AgentInput = None) -> T:
"""This method allows you to get structured output from the agent.
Expand Down Expand Up @@ -467,6 +486,11 @@ def execute() -> T:
future = executor.submit(execute)
return future.result()

@deprecated(
"Agent.structured_output_async method is deprecated."
" You should pass in `structured_output_model` directly into the agent invocation."
" see the <LINK> for more details"
)
async def structured_output_async(self, output_model: Type[T], prompt: AgentInput = None) -> T:
"""This method allows you to get structured output from the agent.
Expand Down Expand Up @@ -530,6 +554,7 @@ async def structured_output_async(self, output_model: Type[T], prompt: AgentInpu
async def stream_async(
self,
prompt: AgentInput = None,
structured_output_model: Type[BaseModel] | None = None,
**kwargs: Any,
) -> AsyncIterator[Any]:
"""Process a natural language prompt and yield events as an async iterator.
Expand All @@ -546,6 +571,7 @@ async def stream_async(
- list[ContentBlock]: Multi-modal content blocks
- list[Message]: Complete messages with roles
- None: Use existing conversation history
structured_output_model: Pydantic model type(s) for structured output (overrides agent default).
**kwargs: Additional parameters to pass to the event loop.
Yields:
Expand Down Expand Up @@ -576,7 +602,7 @@ async def stream_async(

with trace_api.use_span(self.trace_span):
try:
events = self._run_loop(messages, invocation_state=kwargs)
events = self._run_loop(messages, kwargs, structured_output_model)

async for event in events:
event.prepare(invocation_state=kwargs)
Expand All @@ -596,12 +622,18 @@ async def stream_async(
self._end_agent_trace_span(error=e)
raise

async def _run_loop(self, messages: Messages, invocation_state: dict[str, Any]) -> AsyncGenerator[TypedEvent, None]:
async def _run_loop(
self,
messages: Messages,
invocation_state: dict[str, Any],
structured_output_model: Type[BaseModel] | None = None,
) -> AsyncGenerator[TypedEvent, None]:
"""Execute the agent's event loop with the given message and parameters.
Args:
messages: The input messages to add to the conversation.
invocation_state: Additional parameters to pass to the event loop.
structured_output_model: Optional Pydantic model type for structured output.
Yields:
Events from the event loop cycle.
Expand All @@ -614,8 +646,12 @@ async def _run_loop(self, messages: Messages, invocation_state: dict[str, Any])
for message in messages:
self._append_message(message)

structured_output_context = StructuredOutputContext(
structured_output_model or self._default_structured_output_model
)

# Execute the event loop cycle with retry logic for context limits
events = self._execute_event_loop_cycle(invocation_state)
events = self._execute_event_loop_cycle(invocation_state, structured_output_context)
async for event in events:
# Signal from the model provider that the message sent by the user should be redacted,
# likely due to a guardrail.
Expand All @@ -636,24 +672,33 @@ async def _run_loop(self, messages: Messages, invocation_state: dict[str, Any])
self.conversation_manager.apply_management(self)
self.hooks.invoke_callbacks(AfterInvocationEvent(agent=self))

async def _execute_event_loop_cycle(self, invocation_state: dict[str, Any]) -> AsyncGenerator[TypedEvent, None]:
async def _execute_event_loop_cycle(
self, invocation_state: dict[str, Any], structured_output_context: StructuredOutputContext | None = None
) -> AsyncGenerator[TypedEvent, None]:
"""Execute the event loop cycle with retry logic for context window limits.
This internal method handles the execution of the event loop cycle and implements
retry logic for handling context window overflow exceptions by reducing the
conversation context and retrying.
Args:
invocation_state: Additional parameters to pass to the event loop.
structured_output_context: Optional structured output context for this invocation.
Yields:
Events of the loop cycle.
"""
# Add `Agent` to invocation_state to keep backwards-compatibility
invocation_state["agent"] = self

if structured_output_context and structured_output_context.structured_output_tool:
self.tool_registry.register_dynamic_tool(structured_output_context.structured_output_tool)
Copy link
Member

Choose a reason for hiding this comment

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

Where do we remove this? I believe this should be done in the same method as where we register it.

Also consider if there's a Context Manager we could use to ensure that we're calling unregister. There should also be a test


try:
# Execute the main event loop cycle
events = event_loop_cycle(
agent=self,
invocation_state=invocation_state,
structured_output_context=structured_output_context,
)
async for event in events:
yield event
Expand All @@ -666,7 +711,7 @@ async def _execute_event_loop_cycle(self, invocation_state: dict[str, Any]) -> A
if self._session_manager:
self._session_manager.sync_agent(self)

events = self._execute_event_loop_cycle(invocation_state)
events = self._execute_event_loop_cycle(invocation_state, structured_output_context)
async for event in events:
yield event

Expand Down
4 changes: 4 additions & 0 deletions src/strands/agent/agent_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from dataclasses import dataclass
from typing import Any

from pydantic import BaseModel

from ..telemetry.metrics import EventLoopMetrics
from ..types.content import Message
from ..types.streaming import StopReason
Expand All @@ -20,12 +22,14 @@ class AgentResult:
message: The last message generated by the agent.
metrics: Performance metrics collected during processing.
state: Additional state information from the event loop.
structured_output: Parsed structured output when structured_output_model was specified.
"""

stop_reason: StopReason
message: Message
metrics: EventLoopMetrics
state: Any
structured_output: BaseModel | None = None

def __str__(self) -> str:
"""Get the agent's last message as a string.
Expand Down
Loading