Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 25 additions & 0 deletions src/strands/tools/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,31 @@ def tool_spec(self) -> ToolSpec:
"""
return self._tool_spec

@tool_spec.setter
def tool_spec(self, value: ToolSpec) -> None:
"""Set the tool specification.

This allows runtime modification of the tool's schema, enabling dynamic
tool configurations based on feature flags or other runtime conditions.

Args:
value: The new tool specification.

Raises:
ValueError: If the spec fails structural validation (wrong name or
missing required field).
"""
if value.get("name") != self._tool_name:
raise ValueError(
f"cannot change tool name via tool_spec (expected '{self._tool_name}', got '{value.get('name')}')"
)

for field in ("description", "inputSchema"):
if field not in value:
raise ValueError(f"tool_spec must contain '{field}'")

self._tool_spec = value

@property
def tool_type(self) -> str:
"""Get the type of the tool.
Expand Down
25 changes: 25 additions & 0 deletions src/strands/tools/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,31 @@ def tool_spec(self) -> ToolSpec:
"""
return self._tool_spec

@tool_spec.setter
def tool_spec(self, value: ToolSpec) -> None:
"""Set the tool specification.

This allows runtime modification of the tool's schema, enabling dynamic
tool configurations based on feature flags or other runtime conditions.

Args:
value: The new tool specification.

Raises:
ValueError: If the spec fails structural validation (wrong name or
missing required field).
"""
if value.get("name") != self._tool_name:
raise ValueError(
f"cannot change tool name via tool_spec (expected '{self._tool_name}', got '{value.get('name')}')"
)

for field in ("description", "inputSchema"):
if field not in value:
raise ValueError(f"tool_spec must contain '{field}'")

self._tool_spec = value

@property
def supports_hot_reload(self) -> bool:
"""Check if this tool supports automatic reloading when modified.
Expand Down
253 changes: 253 additions & 0 deletions tests/strands/tools/test_tool_spec_setter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
"""Tests for tool_spec setter on DecoratedFunctionTool and PythonAgentTool."""

import pytest

from strands.tools.decorator import tool
from strands.tools.tools import PythonAgentTool
from strands.types.tools import ToolSpec


class TestDecoratedFunctionToolSpecSetter:
"""Tests for DecoratedFunctionTool.tool_spec setter."""

def test_set_tool_spec_replaces_spec(self):
@tool
def my_tool(query: str) -> str:
"""A test tool."""
return query

new_spec: ToolSpec = {
"name": "my_tool",
"description": "Updated tool",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "The query"},
"limit": {"type": "integer", "description": "Max results"},
},
"required": ["query"],
}
},
}
my_tool.tool_spec = new_spec
assert my_tool.tool_spec is new_spec
assert "limit" in my_tool.tool_spec["inputSchema"]["json"]["properties"]

def test_set_tool_spec_persists_across_reads(self):
@tool
def another_tool(x: int) -> int:
"""Another test tool."""
return x

new_spec: ToolSpec = {
"name": "another_tool",
"description": "Modified",
"inputSchema": {
"json": {
"type": "object",
"properties": {"x": {"type": "integer"}, "y": {"type": "integer"}},
"required": ["x"],
}
},
}
another_tool.tool_spec = new_spec
assert another_tool.tool_spec["description"] == "Modified"
assert another_tool.tool_spec["description"] == "Modified"

def test_add_property_via_setter(self):
@tool
def dynamic_tool(base: str) -> str:
"""A dynamic tool."""
return base

spec = dynamic_tool.tool_spec.copy()
spec["inputSchema"] = dynamic_tool.tool_spec["inputSchema"].copy()
spec["inputSchema"]["json"] = dynamic_tool.tool_spec["inputSchema"]["json"].copy()
spec["inputSchema"]["json"]["properties"] = dynamic_tool.tool_spec["inputSchema"]["json"]["properties"].copy()
spec["inputSchema"]["json"]["properties"]["extra"] = {
"type": "string",
"description": "Extra param",
}
dynamic_tool.tool_spec = spec
assert "extra" in dynamic_tool.tool_spec["inputSchema"]["json"]["properties"]

def test_set_tool_spec_rejects_name_change(self):
@tool
def my_tool(query: str) -> str:
"""A test tool."""
return query

bad_spec: ToolSpec = {
"name": "wrong_name",
"description": "Updated tool",
"inputSchema": {"json": {"type": "object", "properties": {}, "required": []}},
}
with pytest.raises(ValueError, match="cannot change tool name via tool_spec"):
my_tool.tool_spec = bad_spec

def test_set_tool_spec_rejects_missing_description(self):
@tool
def my_tool(query: str) -> str:
"""A test tool."""
return query

bad_spec: ToolSpec = {
"name": "my_tool",
"inputSchema": {"json": {"type": "object", "properties": {}, "required": []}},
}
with pytest.raises(ValueError, match="tool_spec must contain 'description'"):
my_tool.tool_spec = bad_spec

def test_set_tool_spec_rejects_missing_input_schema(self):
@tool
def my_tool(query: str) -> str:
"""A test tool."""
return query

bad_spec: ToolSpec = {
"name": "my_tool",
"description": "Updated tool",
}
with pytest.raises(ValueError, match="tool_spec must contain 'inputSchema'"):
my_tool.tool_spec = bad_spec

def test_set_tool_spec_accepts_bare_input_schema(self):
@tool
def my_tool(query: str) -> str:
"""A test tool."""
return query

bare_spec: ToolSpec = {
"name": "my_tool",
"description": "Bare schema",
"inputSchema": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]},
}
my_tool.tool_spec = bare_spec
assert my_tool.tool_spec is bare_spec

def test_set_tool_spec_accepts_valid_spec(self):
@tool
def my_tool(query: str) -> str:
"""A test tool."""
return query

valid_spec: ToolSpec = {
"name": "my_tool",
"description": "A valid updated spec",
"inputSchema": {
"json": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
}
},
}
my_tool.tool_spec = valid_spec
assert my_tool.tool_spec is valid_spec


class TestPythonAgentToolSpecSetter:
"""Tests for PythonAgentTool.tool_spec setter."""

def _make_tool(self) -> PythonAgentTool:
def func(tool_use, **kwargs):
return {"status": "success", "content": [{"text": "ok"}], "toolUseId": tool_use["toolUseId"]}

spec: ToolSpec = {
"name": "test_tool",
"description": "A test tool",
"inputSchema": {
"json": {
"type": "object",
"properties": {"input": {"type": "string"}},
"required": ["input"],
}
},
}
return PythonAgentTool("test_tool", spec, func)

def test_set_tool_spec(self):
t = self._make_tool()
new_spec: ToolSpec = {
"name": "test_tool",
"description": "Updated",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"input": {"type": "string"},
"extra": {"type": "integer"},
},
"required": ["input"],
}
},
}
t.tool_spec = new_spec
assert t.tool_spec is new_spec
assert "extra" in t.tool_spec["inputSchema"]["json"]["properties"]

def test_set_tool_spec_persists(self):
t = self._make_tool()
new_spec: ToolSpec = {
"name": "test_tool",
"description": "Persisted",
"inputSchema": {"json": {"type": "object", "properties": {}, "required": []}},
}
t.tool_spec = new_spec
assert t.tool_spec["description"] == "Persisted"
assert t.tool_spec["description"] == "Persisted"

def test_set_tool_spec_rejects_name_change(self):
t = self._make_tool()
bad_spec: ToolSpec = {
"name": "wrong_name",
"description": "Updated",
"inputSchema": {"json": {"type": "object", "properties": {}, "required": []}},
}
with pytest.raises(ValueError, match="cannot change tool name via tool_spec"):
t.tool_spec = bad_spec

def test_set_tool_spec_rejects_missing_description(self):
t = self._make_tool()
bad_spec: ToolSpec = {
"name": "test_tool",
"inputSchema": {"json": {"type": "object", "properties": {}, "required": []}},
}
with pytest.raises(ValueError, match="tool_spec must contain 'description'"):
t.tool_spec = bad_spec

def test_set_tool_spec_rejects_missing_input_schema(self):
t = self._make_tool()
bad_spec: ToolSpec = {
"name": "test_tool",
"description": "Updated",
}
with pytest.raises(ValueError, match="tool_spec must contain 'inputSchema'"):
t.tool_spec = bad_spec

def test_set_tool_spec_accepts_bare_input_schema(self):
t = self._make_tool()
bare_spec: ToolSpec = {
"name": "test_tool",
"description": "Bare schema",
"inputSchema": {"type": "object", "properties": {"input": {"type": "string"}}, "required": ["input"]},
}
t.tool_spec = bare_spec
assert t.tool_spec is bare_spec

def test_set_tool_spec_accepts_valid_spec(self):
t = self._make_tool()
valid_spec: ToolSpec = {
"name": "test_tool",
"description": "A valid updated spec",
"inputSchema": {
"json": {
"type": "object",
"properties": {"input": {"type": "string"}},
"required": ["input"],
}
},
}
t.tool_spec = valid_spec
assert t.tool_spec is valid_spec
Loading