Skip to content

Commit 5446548

Browse files
committed
feat(tools): add replace method to ToolRegistry
1 parent 89bab98 commit 5446548

File tree

3 files changed

+139
-2
lines changed

3 files changed

+139
-2
lines changed

src/strands/tools/registry.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,34 @@ def register_tool(self, tool: AgentTool) -> None:
277277
list(self.dynamic_tools.keys()),
278278
)
279279

280+
def replace(self, tool_name: str, new_tool: AgentTool) -> None:
281+
"""Replace an existing tool with a new implementation.
282+
283+
This performs an atomic swap of the tool implementation in the registry.
284+
The replacement takes effect on the next agent invocation.
285+
286+
Args:
287+
tool_name: Name of the tool to replace.
288+
new_tool: New tool implementation.
289+
290+
Raises:
291+
ValueError: If the tool doesn't exist or if names don't match.
292+
"""
293+
if tool_name not in self.registry:
294+
raise ValueError(f"Cannot replace tool '{tool_name}' - tool does not exist")
295+
296+
if new_tool.tool_name != tool_name:
297+
raise ValueError(f"Tool names must match - expected '{tool_name}', got '{new_tool.tool_name}'")
298+
299+
# Atomic replacement in main registry
300+
self.registry[tool_name] = new_tool
301+
302+
# Update dynamic_tools to match new tool's dynamic status
303+
if new_tool.is_dynamic:
304+
self.dynamic_tools[tool_name] = new_tool
305+
elif tool_name in self.dynamic_tools:
306+
del self.dynamic_tools[tool_name]
307+
280308
def get_tools_dirs(self) -> List[Path]:
281309
"""Get all tool directory paths.
282310

tests/strands/agent/test_agent.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2240,8 +2240,8 @@ def test_agent_backwards_compatibility_single_text_block():
22402240

22412241
# Should extract text for backwards compatibility
22422242
assert agent.system_prompt == text
2243-
2244-
2243+
2244+
22452245
@pytest.mark.parametrize(
22462246
"content, expected",
22472247
[

tests/strands/tools/test_registry.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,3 +389,112 @@ async def track_load_tools(*args, **kwargs):
389389

390390
# Verify add_consumer was called with the registry ID
391391
mock_provider.add_consumer.assert_called_once_with(registry._registry_id)
392+
393+
394+
def test_tool_registry_replace_existing_tool():
395+
"""Test replacing an existing tool."""
396+
old_tool = MagicMock()
397+
old_tool.tool_name = "my_tool"
398+
old_tool.is_dynamic = False
399+
old_tool.supports_hot_reload = False
400+
401+
new_tool = MagicMock()
402+
new_tool.tool_name = "my_tool"
403+
new_tool.is_dynamic = False
404+
405+
registry = ToolRegistry()
406+
registry.register_tool(old_tool)
407+
registry.replace("my_tool", new_tool)
408+
409+
assert registry.registry["my_tool"] == new_tool
410+
411+
412+
def test_tool_registry_replace_nonexistent_tool():
413+
"""Test replacing a tool that doesn't exist raises ValueError."""
414+
new_tool = MagicMock()
415+
new_tool.tool_name = "my_tool"
416+
417+
registry = ToolRegistry()
418+
419+
with pytest.raises(ValueError, match="Cannot replace tool 'my_tool' - tool does not exist"):
420+
registry.replace("my_tool", new_tool)
421+
422+
423+
def test_tool_registry_replace_with_different_name():
424+
"""Test replacing with different name raises ValueError."""
425+
old_tool = MagicMock()
426+
old_tool.tool_name = "old_tool"
427+
old_tool.is_dynamic = False
428+
old_tool.supports_hot_reload = False
429+
430+
new_tool = MagicMock()
431+
new_tool.tool_name = "new_tool"
432+
433+
registry = ToolRegistry()
434+
registry.register_tool(old_tool)
435+
436+
with pytest.raises(ValueError, match="Tool names must match"):
437+
registry.replace("old_tool", new_tool)
438+
439+
440+
def test_tool_registry_replace_dynamic_tool():
441+
"""Test replacing a dynamic tool updates both registries."""
442+
old_tool = MagicMock()
443+
old_tool.tool_name = "dynamic_tool"
444+
old_tool.is_dynamic = True
445+
old_tool.supports_hot_reload = True
446+
447+
new_tool = MagicMock()
448+
new_tool.tool_name = "dynamic_tool"
449+
new_tool.is_dynamic = True
450+
451+
registry = ToolRegistry()
452+
registry.register_tool(old_tool)
453+
registry.replace("dynamic_tool", new_tool)
454+
455+
assert registry.registry["dynamic_tool"] == new_tool
456+
assert registry.dynamic_tools["dynamic_tool"] == new_tool
457+
458+
459+
def test_tool_registry_replace_dynamic_with_non_dynamic():
460+
"""Test replacing a dynamic tool with non-dynamic tool removes from dynamic_tools."""
461+
old_tool = MagicMock()
462+
old_tool.tool_name = "my_tool"
463+
old_tool.is_dynamic = True
464+
old_tool.supports_hot_reload = True
465+
466+
new_tool = MagicMock()
467+
new_tool.tool_name = "my_tool"
468+
new_tool.is_dynamic = False
469+
470+
registry = ToolRegistry()
471+
registry.register_tool(old_tool)
472+
473+
assert "my_tool" in registry.dynamic_tools
474+
475+
registry.replace("my_tool", new_tool)
476+
477+
assert registry.registry["my_tool"] == new_tool
478+
assert "my_tool" not in registry.dynamic_tools
479+
480+
481+
def test_tool_registry_replace_non_dynamic_with_dynamic():
482+
"""Test replacing a non-dynamic tool with dynamic tool adds to dynamic_tools."""
483+
old_tool = MagicMock()
484+
old_tool.tool_name = "my_tool"
485+
old_tool.is_dynamic = False
486+
old_tool.supports_hot_reload = False
487+
488+
new_tool = MagicMock()
489+
new_tool.tool_name = "my_tool"
490+
new_tool.is_dynamic = True
491+
492+
registry = ToolRegistry()
493+
registry.register_tool(old_tool)
494+
495+
assert "my_tool" not in registry.dynamic_tools
496+
497+
registry.replace("my_tool", new_tool)
498+
499+
assert registry.registry["my_tool"] == new_tool
500+
assert registry.dynamic_tools["my_tool"] == new_tool

0 commit comments

Comments
 (0)