Skip to content

feat: add structured output support using Pydantic models #60

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 24 commits into from
Jun 19, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e183907
feat: add structured output support using Pydantic models
theagenticguy May 20, 2025
03942ae
fix: import cleanups and unused vars
theagenticguy May 20, 2025
19a580d
Merge branch 'main' into feature/structured-output
theagenticguy Jun 5, 2025
510def6
feat: wip adding `structured_output` methods
theagenticguy Jun 5, 2025
c3ffbce
feat: wip added structured output to bedrock and anthropic
theagenticguy Jun 5, 2025
0f03889
Merge branch 'strands-agents:main' into feature/structured-output
theagenticguy Jun 5, 2025
dce0a81
feat: litellm structured output and some integ tests
theagenticguy Jun 7, 2025
5262dfc
feat: all structured outputs working, tbd llama api
theagenticguy Jun 8, 2025
2a1f5ed
Merge branch 'strands-agents:main' into feature/structured-output
theagenticguy Jun 8, 2025
23df2c6
feat: updated docstring
theagenticguy Jun 8, 2025
cc78b6f
fix: otel ci dep issue
theagenticguy Jun 8, 2025
e8ef600
fix: remove unnecessary changes and comments
theagenticguy Jun 9, 2025
6eeeaa8
feat: basic test WIP
theagenticguy Jun 9, 2025
51f1f1d
feat: better test coverage
theagenticguy Jun 9, 2025
d5bef96
fix: remove unused fixture
theagenticguy Jun 9, 2025
c66fa32
fix: resolve some comments
theagenticguy Jun 13, 2025
422bc25
fix: inline basemodel classes
theagenticguy Jun 13, 2025
eabf075
feat: update litellm, add checks
theagenticguy Jun 17, 2025
7194d6c
Merge branch 'main' into feature/structured-output
theagenticguy Jun 17, 2025
885d3ac
fix: autoformatting issue
theagenticguy Jun 17, 2025
7308491
feat: resolves comments
theagenticguy Jun 17, 2025
a88c93b
Merge branch 'main' into feature/structured-output
theagenticguy Jun 17, 2025
0216bcc
fix: ollama skip tests, pyproject whitespace diffs
theagenticguy Jun 18, 2025
49ccfb5
Merge branch 'strands-agents:main' into feature/structured-output
theagenticguy Jun 18, 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
Next Next commit
feat: add structured output support using Pydantic models
- Add  method to Agent class for handling structured outputs
- Create structured_output.py utility for converting Pydantic models to tool specs
- Improve error handling when extracting model_id from configuration
- Add integration tests to validate structured output functionality
  • Loading branch information
theagenticguy committed May 20, 2025
commit e183907319c01bc37fd13f40f852e93f5cb83bc6
82 changes: 79 additions & 3 deletions src/strands/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@
import random
from concurrent.futures import ThreadPoolExecutor
from threading import Thread
from typing import Any, AsyncIterator, Callable, Dict, List, Mapping, Optional, Union
from typing import Any, AsyncIterator, Callable, Dict, List, Mapping, Optional, Type, Union
from uuid import uuid4

from opentelemetry import trace
from pydantic import BaseModel

from ..event_loop.event_loop import event_loop_cycle
from ..handlers.callback_handler import CompositeCallbackHandler, PrintingCallbackHandler, null_callback_handler
Expand Down Expand Up @@ -328,7 +329,15 @@ def __call__(self, prompt: str, **kwargs: Any) -> AgentResult:
- metrics: Performance metrics from the event loop
- state: The final state of the event loop
"""
model_id = self.model.config.get("model_id") if hasattr(self.model, "config") else None
# Safely get model_id if available
model_id = None
try:
config = getattr(self.model, "config", None)
if isinstance(config, dict):
model_id = config.get("model_id")
except Exception:
# Ignore any errors accessing model configuration
pass

self.trace_span = self.tracer.start_agent_span(
prompt=prompt,
Expand All @@ -353,6 +362,73 @@ def __call__(self, prompt: str, **kwargs: Any) -> AgentResult:
# Re-raise the exception to preserve original behavior
raise

def with_output(self, prompt: str, output_model: Type[BaseModel]) -> BaseModel:
"""Set the output model for the agent.

Args:
prompt: The prompt to use for the agent.
output_model: The output model to use for the agent.

Returns: the loaded basemodel
"""
from ..tools.structured_output import convert_pydantic_to_bedrock_tool

# Convert the pydantic basemodel to a tool spec
tool_spec = convert_pydantic_to_bedrock_tool(output_model)

# Create a dynamic tool name to avoid collisions
tool_name = f"generate_{output_model.__name__}"
tool_spec["toolSpec"]["name"] = tool_name

# Register the tool with the tool registry
# We need a special type of tool that just passes through the input
from ..tools.tools import PythonAgentTool

# Create a passthrough callback that just returns the input
# with the signature expected by PythonAgentTool
from ..types.tools import ToolResult, ToolUse

def output_callback(
tool_use: ToolUse, model: Any = None, messages: Optional[dict[str, Any]] = None, **kwargs: Any
) -> ToolResult:
# Return the ToolResult explicitly typed
result: ToolResult = {
"toolUseId": tool_use["toolUseId"],
"status": "success",
"content": [{"text": "Output generated successfully"}],
}
return result

# Register the tool
from ..types.tools import ToolResult, ToolUse

tool = PythonAgentTool(tool_name=tool_name, tool_spec=tool_spec["toolSpec"], callback=output_callback)
self.tool_registry.register_tool(tool)

# Call the model with the tool and get the response
# This will run the model and invoke the tool
self(prompt)

# Extract the tool input from the message
# Find the first toolUse in the conversation history
tool_input = None
for message in self.messages:
if message.get("role") == "assistant":
for content in message.get("content", []):
if isinstance(content, dict) and "toolUse" in content:
tool_use = content["toolUse"]
if tool_use.get("name") == tool_name:
tool_input = tool_use.get("input", {})
break
if tool_input:
break

# Create the output model from the tool input and return it
if not tool_input:
raise ValueError(f"Model did not generate a valid {output_model.__name__}")

return output_model(**tool_input)

async def stream_async(self, prompt: str, **kwargs: Any) -> AsyncIterator[Any]:
"""Process a natural language prompt and yield events as an async iterator.

Expand All @@ -367,7 +443,7 @@ async def stream_async(self, prompt: str, **kwargs: Any) -> AsyncIterator[Any]:

Returns:
An async iterator that yields events. Each event is a dictionary containing
information about the current state of processing, such as:
invocation about the current state of processing, such as:
- data: Text content being generated
- complete: Whether this is the final chunk
- current_tool_use: Information about tools being executed
Expand Down
Loading