Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 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
32750ec
Add comprehensive test coverage for structured output and core compon…
afarntrog Oct 6, 2025
d05379e
feat: implement comprehensive structured output system
afarntrog Oct 16, 2025
abb0ee0
Improve deprecation warnings and update mypy config
afarntrog Oct 16, 2025
ab915b6
merge main
afarntrog Oct 16, 2025
f0a0f4d
feat: add structured output support and improve code formatting
afarntrog Oct 17, 2025
73d8734
refactor: extract shared noop tool to common utility module
afarntrog Oct 17, 2025
1d9a367
typing
afarntrog Oct 17, 2025
9a65ca0
typing
afarntrog Oct 20, 2025
f98fc8b
update tests
afarntrog Oct 22, 2025
ac42209
update tests
afarntrog Oct 22, 2025
8e295ca
updates tests
afarntrog Oct 22, 2025
ceeeda7
updates tests
afarntrog Oct 22, 2025
a3f5cf3
updates tests
afarntrog Oct 22, 2025
64697bf
Merge branch 'main' into strucuted_output_891_pr
afarntrog Oct 22, 2025
f805590
updates tests
afarntrog Oct 22, 2025
70eb096
updates tests
afarntrog Oct 22, 2025
05d84de
updates tests
afarntrog Oct 22, 2025
13e22e5
updates tests
afarntrog Oct 22, 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",
]
92 changes: 80 additions & 12 deletions src/strands/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,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 @@ -216,6 +217,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 @@ -251,6 +253,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.
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 @@ -280,8 +286,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 @@ -383,7 +389,12 @@ def tool_names(self) -> list[str]:
return list(all_tools.keys())

def __call__(
self, prompt: AgentInput = None, *, invocation_state: dict[str, Any] | None = None, **kwargs: Any
self,
prompt: AgentInput = None,
*,
invocation_state: dict[str, Any] | None = None,
structured_output_model: Type[BaseModel] | None = None,
**kwargs: Any,
) -> AgentResult:
"""Process a natural language prompt through the agent's event loop.

Expand All @@ -400,6 +411,7 @@ def __call__(
- list[Message]: Complete messages with roles
- None: Use existing conversation history
invocation_state: Additional parameters to pass through the event loop.
structured_output_model: Pydantic model type(s) for structured output (overrides agent default).
**kwargs: Additional parameters to pass through the event loop.[Deprecating]

Returns:
Expand All @@ -409,17 +421,27 @@ def __call__(
- 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, invocation_state=invocation_state, **kwargs))
return asyncio.run(
self.invoke_async(
prompt, invocation_state=invocation_state, structured_output_model=structured_output_model, **kwargs
)
)

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

async def invoke_async(
self, prompt: AgentInput = None, *, invocation_state: dict[str, Any] | None = None, **kwargs: Any
self,
prompt: AgentInput = None,
*,
invocation_state: dict[str, Any] | None = None,
structured_output_model: Type[BaseModel] | None = None,
**kwargs: Any,
) -> AgentResult:
"""Process a natural language prompt through the agent's event loop.

Expand All @@ -436,6 +458,7 @@ async def invoke_async(
- list[Message]: Complete messages with roles
- None: Use existing conversation history
invocation_state: Additional parameters to pass through the event loop.
structured_output_model: Pydantic model type(s) for structured output (overrides agent default).
**kwargs: Additional parameters to pass through the event loop.[Deprecating]

Returns:
Expand All @@ -446,7 +469,9 @@ async def invoke_async(
- metrics: Performance metrics from the event loop
- state: The final state of the event loop
"""
events = self.stream_async(prompt, invocation_state=invocation_state, **kwargs)
events = self.stream_async(
prompt, invocation_state=invocation_state, structured_output_model=structured_output_model, **kwargs
)
async for event in events:
_ = event

Expand All @@ -473,6 +498,13 @@ def structured_output(self, output_model: Type[T], prompt: AgentInput = None) ->
Raises:
ValueError: If no conversation history or prompt is provided.
"""
warnings.warn(
"Agent.structured_output method is deprecated."
" You should pass in `structured_output_model` directly into the agent invocation."
" see: https://strandsagents.com/latest/documentation/docs/user-guide/concepts/agents/structured-output/",
category=DeprecationWarning,
stacklevel=2,
)

def execute() -> T:
return asyncio.run(self.structured_output_async(output_model, prompt))
Expand Down Expand Up @@ -501,6 +533,13 @@ async def structured_output_async(self, output_model: Type[T], prompt: AgentInpu
if self._interrupt_state.activated:
raise RuntimeError("cannot call structured output during interrupt")

warnings.warn(
"Agent.structured_output_async method is deprecated."
" You should pass in `structured_output_model` directly into the agent invocation."
" see: https://strandsagents.com/latest/documentation/docs/user-guide/concepts/agents/structured-output/",
category=DeprecationWarning,
stacklevel=2,
)
self.hooks.invoke_callbacks(BeforeInvocationEvent(agent=self))
with self.tracer.tracer.start_as_current_span(
"execute_structured_output", kind=trace_api.SpanKind.CLIENT
Expand Down Expand Up @@ -545,7 +584,12 @@ async def structured_output_async(self, output_model: Type[T], prompt: AgentInpu
self.hooks.invoke_callbacks(AfterInvocationEvent(agent=self))

async def stream_async(
self, prompt: AgentInput = None, *, invocation_state: dict[str, Any] | None = None, **kwargs: Any
self,
prompt: AgentInput = None,
*,
invocation_state: dict[str, Any] | None = 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 @@ -562,6 +606,7 @@ async def stream_async(
- list[Message]: Complete messages with roles
- None: Use existing conversation history
invocation_state: Additional parameters to pass through the event loop.
structured_output_model: Pydantic model type(s) for structured output (overrides agent default).
**kwargs: Additional parameters to pass to the event loop.[Deprecating]

Yields:
Expand Down Expand Up @@ -606,7 +651,7 @@ async def stream_async(

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

async for event in events:
event.prepare(invocation_state=merged_state)
Expand Down Expand Up @@ -658,12 +703,18 @@ def _resume_interrupt(self, prompt: AgentInput) -> None:

self._interrupt_state.interrupts[interrupt_id].response = interrupt_response

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 @@ -676,8 +727,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 @@ -698,24 +753,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:
structured_output_context.register_tool(self.tool_registry)

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 @@ -728,10 +792,14 @@ 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

finally:
if structured_output_context:
structured_output_context.cleanup(self.tool_registry)

def _convert_prompt_to_messages(self, prompt: AgentInput) -> Messages:
if self._interrupt_state.activated:
return []
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, Sequence, cast

from pydantic import BaseModel

from ..interrupt import Interrupt
from ..telemetry.metrics import EventLoopMetrics
from ..types.content import Message
Expand All @@ -22,13 +24,15 @@ class AgentResult:
metrics: Performance metrics collected during processing.
state: Additional state information from the event loop.
interrupts: List of interrupts if raised by user.
structured_output: Parsed structured output when structured_output_model was specified.
"""

stop_reason: StopReason
message: Message
metrics: EventLoopMetrics
state: Any
interrupts: Sequence[Interrupt] | None = None
structured_output: BaseModel | None = None

def __str__(self) -> str:
"""Get the agent's last message as a string.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@

from typing_extensions import override

from ...tools import tool
from ...tools._tool_helpers import noop_tool
from ...tools.registry import ToolRegistry
from ...types.content import Message
from ...types.exceptions import ContextWindowOverflowException
from ...types.tools import AgentTool
from .conversation_manager import ConversationManager

if TYPE_CHECKING:
Expand Down Expand Up @@ -208,7 +209,7 @@ def _generate_summary(self, messages: List[Message], agent: "Agent") -> Message:
# Add no-op tool if agent has no tools to satisfy tool spec requirement
if not summarization_agent.tool_names:
tool_registry = ToolRegistry()
tool_registry.register_tool(self._noop_tool)
tool_registry.register_tool(cast(AgentTool, noop_tool))
summarization_agent.tool_registry = tool_registry

summarization_agent.messages = messages
Expand Down Expand Up @@ -264,13 +265,3 @@ def _adjust_split_point_for_tool_pairs(self, messages: List[Message], split_poin
raise ContextWindowOverflowException("Unable to trim conversation context!")

return split_point

@tool(name="noop", description="MUST NOT call or summarize")
def _noop_tool(self) -> None:
"""No-op tool to satisfy tool spec requirement when tool messages are present.

Some model provides (e.g., Bedrock) will return an error response if tool uses and tool results are present in
messages without any tool specs configured. Consequently, if the summarization agent has no registered tools,
summarization will fail. As a workaround, we register the no-op tool.
"""
pass
Loading