From c7d02b3ab91104a6017523958e0bf33cf5ab9f68 Mon Sep 17 00:00:00 2001 From: Containerized Agent Date: Thu, 5 Mar 2026 18:53:00 +0000 Subject: [PATCH 1/4] feat: add public tool_spec setter to DecoratedFunctionTool and PythonAgentTool --- src/strands/tools/decorator.py | 12 ++ src/strands/tools/tools.py | 12 ++ tests/strands/tools/test_tool_spec_setter.py | 123 +++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 tests/strands/tools/test_tool_spec_setter.py diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index 70552d6ba..c9d231a1d 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -541,6 +541,18 @@ 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. + """ + self._tool_spec = value + @property def tool_type(self) -> str: """Get the type of the tool. diff --git a/src/strands/tools/tools.py b/src/strands/tools/tools.py index 39e2f3723..86428b66d 100644 --- a/src/strands/tools/tools.py +++ b/src/strands/tools/tools.py @@ -197,6 +197,18 @@ 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. + """ + self._tool_spec = value + @property def supports_hot_reload(self) -> bool: """Check if this tool supports automatic reloading when modified. diff --git a/tests/strands/tools/test_tool_spec_setter.py b/tests/strands/tools/test_tool_spec_setter.py new file mode 100644 index 000000000..47f15908f --- /dev/null +++ b/tests/strands/tools/test_tool_spec_setter.py @@ -0,0 +1,123 @@ +"""Tests for tool_spec setter on DecoratedFunctionTool and PythonAgentTool.""" + +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"] + + +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" From 1d1d4446f4624faf80fba1f6dc20c1e7d0e1cc1e Mon Sep 17 00:00:00 2001 From: Containerized Agent Date: Fri, 6 Mar 2026 20:17:20 +0000 Subject: [PATCH 2/4] fix: add validation to tool_spec setter --- src/strands/tools/decorator.py | 15 +++ src/strands/tools/tools.py | 15 +++ tests/strands/tools/test_tool_spec_setter.py | 130 +++++++++++++++++++ 3 files changed, 160 insertions(+) diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index c9d231a1d..3389583a6 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -550,7 +550,22 @@ def tool_spec(self, value: ToolSpec) -> None: Args: value: The new tool specification. + + Raises: + ValueError: If the spec fails structural validation (wrong name, + missing description, missing inputSchema, or missing json key). """ + 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')}')" + ) + if "description" not in value: + raise ValueError("tool_spec must contain a 'description' field") + if "inputSchema" not in value: + raise ValueError("tool_spec must contain an 'inputSchema' field") + if "json" not in value["inputSchema"]: + raise ValueError("tool_spec 'inputSchema' must contain a 'json' key") + self._tool_spec = value @property diff --git a/src/strands/tools/tools.py b/src/strands/tools/tools.py index 86428b66d..d702eb3d7 100644 --- a/src/strands/tools/tools.py +++ b/src/strands/tools/tools.py @@ -206,7 +206,22 @@ def tool_spec(self, value: ToolSpec) -> None: Args: value: The new tool specification. + + Raises: + ValueError: If the spec fails structural validation (wrong name, + missing description, missing inputSchema, or missing json key). """ + 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')}')" + ) + if "description" not in value: + raise ValueError("tool_spec must contain a 'description' field") + if "inputSchema" not in value: + raise ValueError("tool_spec must contain an 'inputSchema' field") + if "json" not in value["inputSchema"]: + raise ValueError("tool_spec 'inputSchema' must contain a 'json' key") + self._tool_spec = value @property diff --git a/tests/strands/tools/test_tool_spec_setter.py b/tests/strands/tools/test_tool_spec_setter.py index 47f15908f..b0b83610a 100644 --- a/tests/strands/tools/test_tool_spec_setter.py +++ b/tests/strands/tools/test_tool_spec_setter.py @@ -1,5 +1,7 @@ """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 @@ -70,6 +72,80 @@ def dynamic_tool(base: str) -> str: 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 a 'description' field"): + 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 an 'inputSchema' field"): + my_tool.tool_spec = bad_spec + + def test_set_tool_spec_rejects_missing_json_key(self): + @tool + def my_tool(query: str) -> str: + """A test tool.""" + return query + + bad_spec: ToolSpec = { + "name": "my_tool", + "description": "Updated tool", + "inputSchema": {"not_json": {}}, + } + with pytest.raises(ValueError, match="tool_spec 'inputSchema' must contain a 'json' key"): + my_tool.tool_spec = bad_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.""" @@ -121,3 +197,57 @@ def test_set_tool_spec_persists(self): 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 a 'description' field"): + 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 an 'inputSchema' field"): + t.tool_spec = bad_spec + + def test_set_tool_spec_rejects_missing_json_key(self): + t = self._make_tool() + bad_spec: ToolSpec = { + "name": "test_tool", + "description": "Updated", + "inputSchema": {"not_json": {}}, + } + with pytest.raises(ValueError, match="tool_spec 'inputSchema' must contain a 'json' key"): + t.tool_spec = bad_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 From a7865bd2b41650f1d40116ee7d2a86e316f837cf Mon Sep 17 00:00:00 2001 From: Containerized Agent Date: Fri, 6 Mar 2026 20:30:25 +0000 Subject: [PATCH 3/4] refactor: loop over required fields in tool_spec setter validation --- src/strands/tools/decorator.py | 9 +++++---- src/strands/tools/tools.py | 9 +++++---- tests/strands/tools/test_tool_spec_setter.py | 8 ++++---- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index 3389583a6..e9ef0d6dd 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -559,10 +559,11 @@ def tool_spec(self, value: ToolSpec) -> None: raise ValueError( f"cannot change tool name via tool_spec (expected '{self._tool_name}', got '{value.get('name')}')" ) - if "description" not in value: - raise ValueError("tool_spec must contain a 'description' field") - if "inputSchema" not in value: - raise ValueError("tool_spec must contain an 'inputSchema' field") + + for field in ("description", "inputSchema"): + if field not in value: + raise ValueError(f"tool_spec must contain '{field}'") + if "json" not in value["inputSchema"]: raise ValueError("tool_spec 'inputSchema' must contain a 'json' key") diff --git a/src/strands/tools/tools.py b/src/strands/tools/tools.py index d702eb3d7..ca03b86df 100644 --- a/src/strands/tools/tools.py +++ b/src/strands/tools/tools.py @@ -215,10 +215,11 @@ def tool_spec(self, value: ToolSpec) -> None: raise ValueError( f"cannot change tool name via tool_spec (expected '{self._tool_name}', got '{value.get('name')}')" ) - if "description" not in value: - raise ValueError("tool_spec must contain a 'description' field") - if "inputSchema" not in value: - raise ValueError("tool_spec must contain an 'inputSchema' field") + + for field in ("description", "inputSchema"): + if field not in value: + raise ValueError(f"tool_spec must contain '{field}'") + if "json" not in value["inputSchema"]: raise ValueError("tool_spec 'inputSchema' must contain a 'json' key") diff --git a/tests/strands/tools/test_tool_spec_setter.py b/tests/strands/tools/test_tool_spec_setter.py index b0b83610a..121c88fb8 100644 --- a/tests/strands/tools/test_tool_spec_setter.py +++ b/tests/strands/tools/test_tool_spec_setter.py @@ -96,7 +96,7 @@ def my_tool(query: str) -> str: "name": "my_tool", "inputSchema": {"json": {"type": "object", "properties": {}, "required": []}}, } - with pytest.raises(ValueError, match="tool_spec must contain a 'description' field"): + 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): @@ -109,7 +109,7 @@ def my_tool(query: str) -> str: "name": "my_tool", "description": "Updated tool", } - with pytest.raises(ValueError, match="tool_spec must contain an 'inputSchema' field"): + with pytest.raises(ValueError, match="tool_spec must contain 'inputSchema'"): my_tool.tool_spec = bad_spec def test_set_tool_spec_rejects_missing_json_key(self): @@ -214,7 +214,7 @@ def test_set_tool_spec_rejects_missing_description(self): "name": "test_tool", "inputSchema": {"json": {"type": "object", "properties": {}, "required": []}}, } - with pytest.raises(ValueError, match="tool_spec must contain a 'description' field"): + 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): @@ -223,7 +223,7 @@ def test_set_tool_spec_rejects_missing_input_schema(self): "name": "test_tool", "description": "Updated", } - with pytest.raises(ValueError, match="tool_spec must contain an 'inputSchema' field"): + with pytest.raises(ValueError, match="tool_spec must contain 'inputSchema'"): t.tool_spec = bad_spec def test_set_tool_spec_rejects_missing_json_key(self): From 5ebf44d9feead8068d8b15acba8fa933814bb2ec Mon Sep 17 00:00:00 2001 From: Containerized Agent Date: Fri, 6 Mar 2026 20:38:44 +0000 Subject: [PATCH 4/4] fix: drop json key validation from tool_spec setter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The json wrapper inside inputSchema is not required — both normalize_tool_spec and the registry's validate_tool_spec handle bare schemas by wrapping them automatically. The setter should only enforce what the ToolSpec TypedDict requires: name, description, inputSchema. --- src/strands/tools/decorator.py | 7 ++---- src/strands/tools/tools.py | 7 ++---- tests/strands/tools/test_tool_spec_setter.py | 24 ++++++++++---------- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index e9ef0d6dd..f927bd89a 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -552,8 +552,8 @@ def tool_spec(self, value: ToolSpec) -> None: value: The new tool specification. Raises: - ValueError: If the spec fails structural validation (wrong name, - missing description, missing inputSchema, or missing json key). + ValueError: If the spec fails structural validation (wrong name or + missing required field). """ if value.get("name") != self._tool_name: raise ValueError( @@ -564,9 +564,6 @@ def tool_spec(self, value: ToolSpec) -> None: if field not in value: raise ValueError(f"tool_spec must contain '{field}'") - if "json" not in value["inputSchema"]: - raise ValueError("tool_spec 'inputSchema' must contain a 'json' key") - self._tool_spec = value @property diff --git a/src/strands/tools/tools.py b/src/strands/tools/tools.py index ca03b86df..ccfeac323 100644 --- a/src/strands/tools/tools.py +++ b/src/strands/tools/tools.py @@ -208,8 +208,8 @@ def tool_spec(self, value: ToolSpec) -> None: value: The new tool specification. Raises: - ValueError: If the spec fails structural validation (wrong name, - missing description, missing inputSchema, or missing json key). + ValueError: If the spec fails structural validation (wrong name or + missing required field). """ if value.get("name") != self._tool_name: raise ValueError( @@ -220,9 +220,6 @@ def tool_spec(self, value: ToolSpec) -> None: if field not in value: raise ValueError(f"tool_spec must contain '{field}'") - if "json" not in value["inputSchema"]: - raise ValueError("tool_spec 'inputSchema' must contain a 'json' key") - self._tool_spec = value @property diff --git a/tests/strands/tools/test_tool_spec_setter.py b/tests/strands/tools/test_tool_spec_setter.py index 121c88fb8..842146c72 100644 --- a/tests/strands/tools/test_tool_spec_setter.py +++ b/tests/strands/tools/test_tool_spec_setter.py @@ -112,19 +112,19 @@ def my_tool(query: str) -> str: with pytest.raises(ValueError, match="tool_spec must contain 'inputSchema'"): my_tool.tool_spec = bad_spec - def test_set_tool_spec_rejects_missing_json_key(self): + def test_set_tool_spec_accepts_bare_input_schema(self): @tool def my_tool(query: str) -> str: """A test tool.""" return query - bad_spec: ToolSpec = { + bare_spec: ToolSpec = { "name": "my_tool", - "description": "Updated tool", - "inputSchema": {"not_json": {}}, + "description": "Bare schema", + "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}, } - with pytest.raises(ValueError, match="tool_spec 'inputSchema' must contain a 'json' key"): - my_tool.tool_spec = bad_spec + my_tool.tool_spec = bare_spec + assert my_tool.tool_spec is bare_spec def test_set_tool_spec_accepts_valid_spec(self): @tool @@ -226,15 +226,15 @@ def test_set_tool_spec_rejects_missing_input_schema(self): with pytest.raises(ValueError, match="tool_spec must contain 'inputSchema'"): t.tool_spec = bad_spec - def test_set_tool_spec_rejects_missing_json_key(self): + def test_set_tool_spec_accepts_bare_input_schema(self): t = self._make_tool() - bad_spec: ToolSpec = { + bare_spec: ToolSpec = { "name": "test_tool", - "description": "Updated", - "inputSchema": {"not_json": {}}, + "description": "Bare schema", + "inputSchema": {"type": "object", "properties": {"input": {"type": "string"}}, "required": ["input"]}, } - with pytest.raises(ValueError, match="tool_spec 'inputSchema' must contain a 'json' key"): - t.tool_spec = bad_spec + 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()