Skip to content

Commit 98636ae

Browse files
authored
feat: add public tool_spec setter (#1822)
1 parent 2d766c4 commit 98636ae

3 files changed

Lines changed: 303 additions & 0 deletions

File tree

src/strands/tools/decorator.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,31 @@ def tool_spec(self) -> ToolSpec:
543543
"""
544544
return self._tool_spec
545545

546+
@tool_spec.setter
547+
def tool_spec(self, value: ToolSpec) -> None:
548+
"""Set the tool specification.
549+
550+
This allows runtime modification of the tool's schema, enabling dynamic
551+
tool configurations based on feature flags or other runtime conditions.
552+
553+
Args:
554+
value: The new tool specification.
555+
556+
Raises:
557+
ValueError: If the spec fails structural validation (wrong name or
558+
missing required field).
559+
"""
560+
if value.get("name") != self._tool_name:
561+
raise ValueError(
562+
f"cannot change tool name via tool_spec (expected '{self._tool_name}', got '{value.get('name')}')"
563+
)
564+
565+
for field in ("description", "inputSchema"):
566+
if field not in value:
567+
raise ValueError(f"tool_spec must contain '{field}'")
568+
569+
self._tool_spec = value
570+
546571
@property
547572
def tool_type(self) -> str:
548573
"""Get the type of the tool.

src/strands/tools/tools.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,31 @@ def tool_spec(self) -> ToolSpec:
197197
"""
198198
return self._tool_spec
199199

200+
@tool_spec.setter
201+
def tool_spec(self, value: ToolSpec) -> None:
202+
"""Set the tool specification.
203+
204+
This allows runtime modification of the tool's schema, enabling dynamic
205+
tool configurations based on feature flags or other runtime conditions.
206+
207+
Args:
208+
value: The new tool specification.
209+
210+
Raises:
211+
ValueError: If the spec fails structural validation (wrong name or
212+
missing required field).
213+
"""
214+
if value.get("name") != self._tool_name:
215+
raise ValueError(
216+
f"cannot change tool name via tool_spec (expected '{self._tool_name}', got '{value.get('name')}')"
217+
)
218+
219+
for field in ("description", "inputSchema"):
220+
if field not in value:
221+
raise ValueError(f"tool_spec must contain '{field}'")
222+
223+
self._tool_spec = value
224+
200225
@property
201226
def supports_hot_reload(self) -> bool:
202227
"""Check if this tool supports automatic reloading when modified.
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
"""Tests for tool_spec setter on DecoratedFunctionTool and PythonAgentTool."""
2+
3+
import pytest
4+
5+
from strands.tools.decorator import tool
6+
from strands.tools.tools import PythonAgentTool
7+
from strands.types.tools import ToolSpec
8+
9+
10+
class TestDecoratedFunctionToolSpecSetter:
11+
"""Tests for DecoratedFunctionTool.tool_spec setter."""
12+
13+
def test_set_tool_spec_replaces_spec(self):
14+
@tool
15+
def my_tool(query: str) -> str:
16+
"""A test tool."""
17+
return query
18+
19+
new_spec: ToolSpec = {
20+
"name": "my_tool",
21+
"description": "Updated tool",
22+
"inputSchema": {
23+
"json": {
24+
"type": "object",
25+
"properties": {
26+
"query": {"type": "string", "description": "The query"},
27+
"limit": {"type": "integer", "description": "Max results"},
28+
},
29+
"required": ["query"],
30+
}
31+
},
32+
}
33+
my_tool.tool_spec = new_spec
34+
assert my_tool.tool_spec is new_spec
35+
assert "limit" in my_tool.tool_spec["inputSchema"]["json"]["properties"]
36+
37+
def test_set_tool_spec_persists_across_reads(self):
38+
@tool
39+
def another_tool(x: int) -> int:
40+
"""Another test tool."""
41+
return x
42+
43+
new_spec: ToolSpec = {
44+
"name": "another_tool",
45+
"description": "Modified",
46+
"inputSchema": {
47+
"json": {
48+
"type": "object",
49+
"properties": {"x": {"type": "integer"}, "y": {"type": "integer"}},
50+
"required": ["x"],
51+
}
52+
},
53+
}
54+
another_tool.tool_spec = new_spec
55+
assert another_tool.tool_spec["description"] == "Modified"
56+
assert another_tool.tool_spec["description"] == "Modified"
57+
58+
def test_add_property_via_setter(self):
59+
@tool
60+
def dynamic_tool(base: str) -> str:
61+
"""A dynamic tool."""
62+
return base
63+
64+
spec = dynamic_tool.tool_spec.copy()
65+
spec["inputSchema"] = dynamic_tool.tool_spec["inputSchema"].copy()
66+
spec["inputSchema"]["json"] = dynamic_tool.tool_spec["inputSchema"]["json"].copy()
67+
spec["inputSchema"]["json"]["properties"] = dynamic_tool.tool_spec["inputSchema"]["json"]["properties"].copy()
68+
spec["inputSchema"]["json"]["properties"]["extra"] = {
69+
"type": "string",
70+
"description": "Extra param",
71+
}
72+
dynamic_tool.tool_spec = spec
73+
assert "extra" in dynamic_tool.tool_spec["inputSchema"]["json"]["properties"]
74+
75+
def test_set_tool_spec_rejects_name_change(self):
76+
@tool
77+
def my_tool(query: str) -> str:
78+
"""A test tool."""
79+
return query
80+
81+
bad_spec: ToolSpec = {
82+
"name": "wrong_name",
83+
"description": "Updated tool",
84+
"inputSchema": {"json": {"type": "object", "properties": {}, "required": []}},
85+
}
86+
with pytest.raises(ValueError, match="cannot change tool name via tool_spec"):
87+
my_tool.tool_spec = bad_spec
88+
89+
def test_set_tool_spec_rejects_missing_description(self):
90+
@tool
91+
def my_tool(query: str) -> str:
92+
"""A test tool."""
93+
return query
94+
95+
bad_spec: ToolSpec = {
96+
"name": "my_tool",
97+
"inputSchema": {"json": {"type": "object", "properties": {}, "required": []}},
98+
}
99+
with pytest.raises(ValueError, match="tool_spec must contain 'description'"):
100+
my_tool.tool_spec = bad_spec
101+
102+
def test_set_tool_spec_rejects_missing_input_schema(self):
103+
@tool
104+
def my_tool(query: str) -> str:
105+
"""A test tool."""
106+
return query
107+
108+
bad_spec: ToolSpec = {
109+
"name": "my_tool",
110+
"description": "Updated tool",
111+
}
112+
with pytest.raises(ValueError, match="tool_spec must contain 'inputSchema'"):
113+
my_tool.tool_spec = bad_spec
114+
115+
def test_set_tool_spec_accepts_bare_input_schema(self):
116+
@tool
117+
def my_tool(query: str) -> str:
118+
"""A test tool."""
119+
return query
120+
121+
bare_spec: ToolSpec = {
122+
"name": "my_tool",
123+
"description": "Bare schema",
124+
"inputSchema": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]},
125+
}
126+
my_tool.tool_spec = bare_spec
127+
assert my_tool.tool_spec is bare_spec
128+
129+
def test_set_tool_spec_accepts_valid_spec(self):
130+
@tool
131+
def my_tool(query: str) -> str:
132+
"""A test tool."""
133+
return query
134+
135+
valid_spec: ToolSpec = {
136+
"name": "my_tool",
137+
"description": "A valid updated spec",
138+
"inputSchema": {
139+
"json": {
140+
"type": "object",
141+
"properties": {"query": {"type": "string"}},
142+
"required": ["query"],
143+
}
144+
},
145+
}
146+
my_tool.tool_spec = valid_spec
147+
assert my_tool.tool_spec is valid_spec
148+
149+
150+
class TestPythonAgentToolSpecSetter:
151+
"""Tests for PythonAgentTool.tool_spec setter."""
152+
153+
def _make_tool(self) -> PythonAgentTool:
154+
def func(tool_use, **kwargs):
155+
return {"status": "success", "content": [{"text": "ok"}], "toolUseId": tool_use["toolUseId"]}
156+
157+
spec: ToolSpec = {
158+
"name": "test_tool",
159+
"description": "A test tool",
160+
"inputSchema": {
161+
"json": {
162+
"type": "object",
163+
"properties": {"input": {"type": "string"}},
164+
"required": ["input"],
165+
}
166+
},
167+
}
168+
return PythonAgentTool("test_tool", spec, func)
169+
170+
def test_set_tool_spec(self):
171+
t = self._make_tool()
172+
new_spec: ToolSpec = {
173+
"name": "test_tool",
174+
"description": "Updated",
175+
"inputSchema": {
176+
"json": {
177+
"type": "object",
178+
"properties": {
179+
"input": {"type": "string"},
180+
"extra": {"type": "integer"},
181+
},
182+
"required": ["input"],
183+
}
184+
},
185+
}
186+
t.tool_spec = new_spec
187+
assert t.tool_spec is new_spec
188+
assert "extra" in t.tool_spec["inputSchema"]["json"]["properties"]
189+
190+
def test_set_tool_spec_persists(self):
191+
t = self._make_tool()
192+
new_spec: ToolSpec = {
193+
"name": "test_tool",
194+
"description": "Persisted",
195+
"inputSchema": {"json": {"type": "object", "properties": {}, "required": []}},
196+
}
197+
t.tool_spec = new_spec
198+
assert t.tool_spec["description"] == "Persisted"
199+
assert t.tool_spec["description"] == "Persisted"
200+
201+
def test_set_tool_spec_rejects_name_change(self):
202+
t = self._make_tool()
203+
bad_spec: ToolSpec = {
204+
"name": "wrong_name",
205+
"description": "Updated",
206+
"inputSchema": {"json": {"type": "object", "properties": {}, "required": []}},
207+
}
208+
with pytest.raises(ValueError, match="cannot change tool name via tool_spec"):
209+
t.tool_spec = bad_spec
210+
211+
def test_set_tool_spec_rejects_missing_description(self):
212+
t = self._make_tool()
213+
bad_spec: ToolSpec = {
214+
"name": "test_tool",
215+
"inputSchema": {"json": {"type": "object", "properties": {}, "required": []}},
216+
}
217+
with pytest.raises(ValueError, match="tool_spec must contain 'description'"):
218+
t.tool_spec = bad_spec
219+
220+
def test_set_tool_spec_rejects_missing_input_schema(self):
221+
t = self._make_tool()
222+
bad_spec: ToolSpec = {
223+
"name": "test_tool",
224+
"description": "Updated",
225+
}
226+
with pytest.raises(ValueError, match="tool_spec must contain 'inputSchema'"):
227+
t.tool_spec = bad_spec
228+
229+
def test_set_tool_spec_accepts_bare_input_schema(self):
230+
t = self._make_tool()
231+
bare_spec: ToolSpec = {
232+
"name": "test_tool",
233+
"description": "Bare schema",
234+
"inputSchema": {"type": "object", "properties": {"input": {"type": "string"}}, "required": ["input"]},
235+
}
236+
t.tool_spec = bare_spec
237+
assert t.tool_spec is bare_spec
238+
239+
def test_set_tool_spec_accepts_valid_spec(self):
240+
t = self._make_tool()
241+
valid_spec: ToolSpec = {
242+
"name": "test_tool",
243+
"description": "A valid updated spec",
244+
"inputSchema": {
245+
"json": {
246+
"type": "object",
247+
"properties": {"input": {"type": "string"}},
248+
"required": ["input"],
249+
}
250+
},
251+
}
252+
t.tool_spec = valid_spec
253+
assert t.tool_spec is valid_spec

0 commit comments

Comments
 (0)