Skip to content

Commit c471eb8

Browse files
author
Vamil Gandhi
committed
feat: add optional outputSchema support for tool specifications
- Add outputSchema as optional field in ToolSpec TypedDict - Implement filter_tool_specs() method in Model base class - Filter tool specs centrally in streaming.py before passing to models - Add supports_tool_output_schema config option (default: False) - Update test fixtures to support new filtering method - Add comprehensive tests for outputSchema filtering This allows agents to better understand tool outputs for improved planning and tool chaining, while maintaining full backward compatibility. Model providers that don't support outputSchema are protected by filtering it out unless explicitly enabled. Closes #787
1 parent b568864 commit c471eb8

File tree

7 files changed

+300
-4
lines changed

7 files changed

+300
-4
lines changed

examples/output_schema_example.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Example demonstrating the use of outputSchema in tool specifications.
2+
3+
This example shows how to define tools with outputSchema and how to enable
4+
or disable outputSchema support for different model providers.
5+
"""
6+
7+
from strands.types.tools import ToolSpec
8+
9+
# Example 1: Tool with outputSchema defined
10+
tool_with_output_schema = ToolSpec(
11+
name="get_weather",
12+
description="Get the current weather for a location",
13+
inputSchema={
14+
"json": {
15+
"type": "object",
16+
"properties": {
17+
"location": {"type": "string", "description": "City name"},
18+
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "default": "celsius"},
19+
},
20+
"required": ["location"],
21+
}
22+
},
23+
outputSchema={
24+
"json": {
25+
"type": "object",
26+
"properties": {
27+
"temperature": {"type": "number"},
28+
"unit": {"type": "string"},
29+
"description": {"type": "string"},
30+
"humidity": {"type": "number", "minimum": 0, "maximum": 100},
31+
},
32+
"required": ["temperature", "unit", "description"],
33+
}
34+
},
35+
)
36+
37+
# Example 2: Tool without outputSchema (backward compatible)
38+
tool_without_output_schema = ToolSpec(
39+
name="calculate",
40+
description="Perform a calculation",
41+
inputSchema={
42+
"json": {
43+
"type": "object",
44+
"properties": {"expression": {"type": "string", "description": "Mathematical expression"}},
45+
"required": ["expression"],
46+
}
47+
},
48+
)
49+
50+
51+
# Example 3: Using with model providers
52+
def example_with_openai():
53+
"""Example using OpenAI with outputSchema support."""
54+
from strands.models.openai import OpenAIModel
55+
56+
# Enable outputSchema support for compatible models
57+
model = OpenAIModel(
58+
model_id="gpt-4",
59+
supports_tool_output_schema=True, # Enable outputSchema support
60+
)
61+
62+
# The model will receive tool specs with outputSchema included
63+
# This helps the model understand the expected output format
64+
return model
65+
66+
67+
def example_with_bedrock():
68+
"""Example using Bedrock (doesn't support outputSchema)."""
69+
from strands.models.bedrock import BedrockModel
70+
71+
# By default, outputSchema is filtered out for compatibility
72+
model = BedrockModel(
73+
model_id="anthropic.claude-3-sonnet-20240229-v1:0",
74+
# supports_tool_output_schema=False is the default
75+
)
76+
77+
# The model will receive tool specs without outputSchema
78+
# to avoid validation errors from the Bedrock API
79+
return model
80+
81+
82+
def example_agent_planning():
83+
"""Example showing how agents can use outputSchema for planning.
84+
85+
When agents have access to outputSchema, they can:
86+
1. Better understand what each tool returns
87+
2. Generate more accurate execution plans
88+
3. Prepare appropriate parameter values for subsequent tool calls
89+
4. Chain tools together more effectively
90+
"""
91+
tools = [tool_with_output_schema, tool_without_output_schema]
92+
93+
# Agents can inspect outputSchema to understand tool capabilities
94+
for tool in tools:
95+
print(f"Tool: {tool['name']}")
96+
print(f" Input: {tool['inputSchema']}")
97+
if "outputSchema" in tool:
98+
print(f" Output: {tool['outputSchema']}")
99+
else:
100+
print(" Output: Not specified")
101+
102+
return tools
103+
104+
105+
if __name__ == "__main__":
106+
print("Tool with outputSchema:")
107+
print(tool_with_output_schema)
108+
print("\nTool without outputSchema:")
109+
print(tool_without_output_schema)
110+
print("\n" + "=" * 50)
111+
print("\nAgent planning example:")
112+
example_agent_planning()

src/strands/event_loop/streaming.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,9 @@ async def stream_messages(
337337
logger.debug("model=<%s> | streaming messages", model)
338338

339339
messages = remove_blank_messages_content_text(messages)
340-
chunks = model.stream(messages, tool_specs if tool_specs else None, system_prompt)
340+
# Filter outputschema spec based on model configuration until all models supports it.
341+
filtered_tool_specs = model.filter_tool_specs(tool_specs) if tool_specs else None
342+
chunks = model.stream(messages, filtered_tool_specs, system_prompt)
341343

342344
async for event in process_stream(chunks):
343345
yield event

src/strands/models/model.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import abc
44
import logging
5-
from typing import Any, AsyncGenerator, AsyncIterable, Optional, Type, TypeVar, Union
5+
from typing import Any, AsyncGenerator, AsyncIterable, Optional, Type, TypeVar, Union, cast
66

77
from pydantic import BaseModel
88

@@ -22,6 +22,27 @@ class Model(abc.ABC):
2222
standardized way to configure and process requests for different AI model providers.
2323
"""
2424

25+
def filter_tool_specs(self, tool_specs: Optional[list[ToolSpec]]) -> Optional[list[ToolSpec]]:
26+
"""Filter tool specifications based on model configuration.
27+
28+
By default, this removes the outputSchema field from tool specs unless
29+
the model configuration explicitly enables it via `supports_tool_output_schema`.
30+
31+
Args:
32+
tool_specs: List of tool specifications to filter.
33+
34+
Returns:
35+
Filtered tool specifications safe for the model provider.
36+
"""
37+
if not tool_specs:
38+
return tool_specs
39+
40+
config = self.get_config()
41+
if isinstance(config, dict) and config.get("supports_tool_output_schema", False):
42+
return tool_specs
43+
44+
return cast(list[ToolSpec], [{k: v for k, v in spec.items() if k != "outputSchema"} for spec in tool_specs])
45+
2546
@abc.abstractmethod
2647
# pragma: no cover
2748
def update_config(self, **model_config: Any) -> None:

src/strands/types/tools.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,22 @@
2020
"""Type alias for JSON Schema dictionaries."""
2121

2222

23-
class ToolSpec(TypedDict):
23+
class ToolSpec(TypedDict, total=False):
2424
"""Specification for a tool that can be used by an agent.
2525
2626
Attributes:
2727
description: A human-readable description of what the tool does.
2828
inputSchema: JSON Schema defining the expected input parameters.
2929
name: The unique name of the tool.
30+
outputSchema: Optional JSON Schema defining the expected output format.
31+
Note: Not all model providers support this field. Providers that don't
32+
support it should filter it out before sending to their API.
3033
"""
3134

3235
description: str
3336
inputSchema: JSONSchema
3437
name: str
38+
outputSchema: JSONSchema
3539

3640

3741
class Tool(TypedDict):

tests/strands/agent/test_agent.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ async def stream(*args, **kwargs):
4949
mock = unittest.mock.Mock(spec=getattr(request, "param", None))
5050
mock.configure_mock(mock_stream=unittest.mock.MagicMock())
5151
mock.stream.side_effect = stream
52+
mock.filter_tool_specs = lambda tool_specs: tool_specs
53+
mock.get_config.return_value = {}
5254

5355
return mock
5456

tests/strands/event_loop/test_event_loop.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ def mock_time():
3333

3434
@pytest.fixture
3535
def model():
36-
return unittest.mock.Mock()
36+
mock = unittest.mock.Mock()
37+
mock.filter_tool_specs.side_effect = lambda tool_specs: tool_specs
38+
return mock
3739

3840

3941
@pytest.fixture
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Tests for outputSchema filtering in model providers."""
2+
3+
from strands.models.model import Model
4+
from strands.types.tools import ToolSpec
5+
6+
7+
class MockModel(Model):
8+
"""Mock model implementation for testing."""
9+
10+
def __init__(self, supports_tool_output_schema: bool = False):
11+
self.config = {"supports_tool_output_schema": supports_tool_output_schema}
12+
13+
def update_config(self, **model_config):
14+
self.config.update(model_config)
15+
16+
def get_config(self):
17+
return self.config
18+
19+
def structured_output(self, output_model, prompt, system_prompt=None, **kwargs):
20+
pass
21+
22+
def stream(self, messages, tool_specs=None, system_prompt=None, **kwargs):
23+
pass
24+
25+
26+
def test_filter_tool_specs_removes_output_schema_by_default():
27+
"""Test that outputSchema is removed when not explicitly supported."""
28+
model = MockModel(supports_tool_output_schema=False)
29+
30+
tool_specs = [
31+
ToolSpec(
32+
name="test_tool",
33+
description="A test tool",
34+
inputSchema={"json": {"type": "object"}},
35+
outputSchema={"json": {"type": "string"}},
36+
)
37+
]
38+
39+
filtered = model.filter_tool_specs(tool_specs)
40+
41+
assert len(filtered) == 1
42+
assert "name" in filtered[0]
43+
assert "description" in filtered[0]
44+
assert "inputSchema" in filtered[0]
45+
assert "outputSchema" not in filtered[0]
46+
47+
48+
def test_filter_tool_specs_preserves_output_schema_when_supported():
49+
"""Test that outputSchema is preserved when explicitly supported."""
50+
model = MockModel(supports_tool_output_schema=True)
51+
52+
tool_specs = [
53+
ToolSpec(
54+
name="test_tool",
55+
description="A test tool",
56+
inputSchema={"json": {"type": "object"}},
57+
outputSchema={"json": {"type": "string"}},
58+
)
59+
]
60+
61+
filtered = model.filter_tool_specs(tool_specs)
62+
63+
assert len(filtered) == 1
64+
assert filtered[0] == tool_specs[0]
65+
assert "outputSchema" in filtered[0]
66+
67+
68+
def test_filter_tool_specs_handles_missing_output_schema():
69+
"""Test that tools without outputSchema work correctly."""
70+
model = MockModel(supports_tool_output_schema=False)
71+
72+
tool_specs = [ToolSpec(name="test_tool", description="A test tool", inputSchema={"json": {"type": "object"}})]
73+
74+
filtered = model.filter_tool_specs(tool_specs)
75+
76+
assert len(filtered) == 1
77+
assert "name" in filtered[0]
78+
assert "description" in filtered[0]
79+
assert "inputSchema" in filtered[0]
80+
assert "outputSchema" not in filtered[0]
81+
82+
83+
def test_filter_tool_specs_handles_none():
84+
"""Test that None tool_specs returns None."""
85+
model = MockModel()
86+
87+
filtered = model.filter_tool_specs(None)
88+
89+
assert filtered is None
90+
91+
92+
def test_filter_tool_specs_handles_empty_list():
93+
"""Test that empty list returns empty list."""
94+
model = MockModel()
95+
96+
filtered = model.filter_tool_specs([])
97+
98+
assert filtered == []
99+
100+
101+
def test_filter_tool_specs_multiple_tools():
102+
"""Test filtering multiple tools with mixed outputSchema presence."""
103+
model = MockModel(supports_tool_output_schema=False)
104+
105+
tool_specs = [
106+
ToolSpec(
107+
name="tool1",
108+
description="First tool",
109+
inputSchema={"json": {"type": "object"}},
110+
outputSchema={"json": {"type": "string"}},
111+
),
112+
ToolSpec(name="tool2", description="Second tool", inputSchema={"json": {"type": "object"}}),
113+
ToolSpec(
114+
name="tool3",
115+
description="Third tool",
116+
inputSchema={"json": {"type": "object"}},
117+
outputSchema={"json": {"type": "array"}},
118+
),
119+
]
120+
121+
filtered = model.filter_tool_specs(tool_specs)
122+
123+
assert len(filtered) == 3
124+
for spec in filtered:
125+
assert "outputSchema" not in spec
126+
assert "name" in spec
127+
assert "description" in spec
128+
assert "inputSchema" in spec
129+
130+
131+
def test_update_config_changes_output_schema_support():
132+
"""Test that updating config can change outputSchema support."""
133+
model = MockModel(supports_tool_output_schema=False)
134+
135+
tool_specs = [
136+
ToolSpec(
137+
name="test_tool",
138+
description="A test tool",
139+
inputSchema={"json": {"type": "object"}},
140+
outputSchema={"json": {"type": "string"}},
141+
)
142+
]
143+
144+
# Initially, outputSchema should be filtered out
145+
filtered = model.filter_tool_specs(tool_specs)
146+
assert "outputSchema" not in filtered[0]
147+
148+
# Update config to support outputSchema
149+
model.update_config(supports_tool_output_schema=True)
150+
151+
# Now outputSchema should be preserved
152+
filtered = model.filter_tool_specs(tool_specs)
153+
assert "outputSchema" in filtered[0]

0 commit comments

Comments
 (0)