Skip to content

refactor: update tool registry interface #468

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

yakshpradippatel
Copy link
Member

@yakshpradippatel yakshpradippatel commented Jul 15, 2025

Description

[DO NOT MERGE SINCE THIS IS A BACKWARDS INCOMPATIBLE CHANGE]

New CRUDL interface for Tool Registry for customers to directly use in their codebase

create_tool(tool)
read_tool(tool_name)
update_tool(tool)
delete_tool(tool_name)
list_tools()

Related Issues

https://github.com/strands-agents/private-sdk-python-staging/issues/26

Documentation PR

N/A

Type of Change

Breaking change to the ToolRegistry interface

Testing

  • I ran hatch run prepare

Checklist

  • I have read the CONTRIBUTING document
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • [] Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

except Exception as e:
logger.warning("tool_name=<%s> | failed to create function tool | %s", name, e)

return tools

def _update_tool_config(self, tool_config: Dict[str, Any], new_tool: NewToolDict) -> None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need NewToolDict here? Can we just pass in ToolSpec which is already defined?

Comment on lines 61 to 62
providing a clean single interface for all tool registration needs. This replaces
the previous multi-step pattern for better separation of concerns.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets not mention what this replaces

- Dictionaries with name/path keys
- Instance of an AgentTool
load_tools_from_directory: Whether to automatically discover and load tools
from the ./tools/ directory.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Indents seem a little off here

logger.debug("tool_modules=<%s> | discovered", list(tool_modules.keys()))
return tool_modules

def _initialize_tools(self, load_tools_from_directory: bool = False) -> None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rename this to something like _load_tools_from_directory? And then instead of passing in the boolean, we conditionally call the method?

self.registry: Dict[str, AgentTool] = {}
self.dynamic_tools: Dict[str, AgentTool] = {}
self.tool_config: Optional[Dict[str, Any]] = None

def register_tools(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What methods in this class are meant to be public or private? If the only public method is register_tools, can we put an _ before the method names of the others?

Comment on lines 48 to 50
self.registry: Dict[str, AgentTool] = {}
self.dynamic_tools: Dict[str, AgentTool] = {}
self.tool_config: Optional[Dict[str, Any]] = None
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these attributes public or private? If private, and we put an _ prefix on them?

Comment on lines 106 to 110
except Exception:
# Restore original state on any failure (atomic operation)
self.registry = original_registry
self.dynamic_tools = original_dynamic_tools
raise
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to restore the tools here? If there is an unknown exception, it wont really matter if the tools are restored or not right? This should also simplify the logic in this function.

@@ -285,15 +284,18 @@ def __init__(
self.record_direct_tool_call = record_direct_tool_call
self.load_tools_from_directory = load_tools_from_directory
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like we don't use this outside of init anymore so I think we can go ahead and remove it as a member variable.

else:
logger.warning("tool=<%s> | unrecognized tool specification", tool)
# Process the tool - this will validate and register the tool
created_tool_name = self._process_tool(tool, validate_unique=True)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like we are checking if tool name is unique multiple times. We should consolidate this logic.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_process_tool is extracting the name with similar logic to _extract_tool_name. We should try to remove the duplication.

# Update tool configuration
if created_tool_name in self.agent_tools:
tool_spec = self.agent_tools[created_tool_name].tool_spec
self._update_tool_config(self._tool_config, tool_spec)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of _tool_config? I only see us adding to it and deleting from it. I'm not seeing where we actually use it.

Comment on lines 110 to 111
spec = normalize_tool_spec(spec)
self._validate_tool_spec(spec)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to normalize and validate the tool spec on every read? For efficiency, is this something that can be done upon creating or updating the tool?

return tool_name
except Exception as e:
# Restore the original tool if the update fails
if original_tool:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: We shouldn't need this condition. original_tool should be guaranteed to exist.

Raises:
ValueError: If the specified tool doesn't exist.
"""
if tool_name is None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: We shouldn't need this condition since we are using type hints.


# Make a backup of the tool in case we need to restore it
original_tool = self.agent_tools.get(tool_name)
original_hot_reload = self._hot_reload_tools.get(tool_name)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the difference between original_tool and original_hot_reload? Also, how is _hot_reload_tools used? I only see us adding to it and deleting from it. I don't see us using it though.

Comment on lines 243 to 244
spec = normalize_tool_spec(spec)
self._validate_tool_spec(spec)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question as in read_tool, do we need to normalize and validate here?

@pgrayy
Copy link
Member

pgrayy commented Jul 18, 2025

Do you think there is a way to simplify the tool registry? I see we are adding 200 more lines of code and I am worried it's going to become a bit more challenging to maintain. Just looking at some of the method names, it seems like their might be some redundancy we can eliminate. For example, I see we have the following:

  • _find_tool_directories
  • _load_tool_from_filepath
  • _discover_tool_modules
  • _load_directory_tools

@@ -285,15 +284,18 @@ def __init__(
self.record_direct_tool_call = record_direct_tool_call
self.load_tools_from_directory = load_tools_from_directory

self.tool_registry = ToolRegistry()
# Initialize the tool registry with the directory loading option
self.tool_registry = ToolRegistry(load_tools_from_directory=self.load_tools_from_directory)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're changing this interface can we also allow the directories to be passed for watching ^^

Idea is we're only allowing ./tools directory to be loaded but customers can pass their directories like:

Agent(tool_directories=["./tools", -> default, ...]

if tools is not None:
self.tool_registry.process_tools(tools)
# Initialize individual tools if provided
for tool in tools or []:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: for tool in tools or []: do we need or [] here ^^ 🤔

This module provides the central registry for all tools available to the agent, including discovery, validation, and
invocation capabilities.
This module provides a central registry for managing agent tools, including discovery,
validation, registration, and invocation capabilities.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add detailed docstrings here for agents, so they can immediately understand how to use the registry 🤔 💭

updated_tools.append(tool_name)
except Exception as e:
logger.warning("tool_name=<%s> | failed to update from directory | %s", tool_name, e)
# Fail atomically - if any tool fails to update, the entire operation fails
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe if one tool failed to update we can skip that tool to be loaded instead of raising a ValueError ^^

A list of Path objects for current working directory's "./tools/".
"""
# Current working directory's tools directory
cwd_tools_dir = Path.cwd() / "tools"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we allow people to set the tools directories from Agent interface?

@@ -24,244 +24,318 @@


class ToolRegistry:
"""Central registry for all tools available to the agent.
"""Agent Tool CRUD Interface.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the "Central registry for all tools available to the agent." hits closer to the mark TBH. As someone reading through the code, the next sentence tells me the same thing so I'd rather see something like:

Contains the collection of tools available to the agent.

if load_tools_from_directory:
self._load_directory_tools()

def create_tool(self, tool: Union[str, dict[str, str], Any]) -> str:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure that create makes sense here. In some cases, it's wrapping an instance of an AgentTool, in other cases it's adding it directly. IMHO, here it makes more sense that this would be called add_tool or simply add

if load_tools_from_directory:
self._load_directory_tools()

def create_tool(self, tool: Union[str, dict[str, str], Any]) -> str:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason not to return the wrapped tool instance AgentTool vs the name of the tool?


return created_tool_name

def read_tool(self, tool_name: str) -> ToolSpec:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per the description, this seems more like read_tool_spec instead of read_tool

try:
# Update the tool
self._update_single_tool(tool, validate_unique=False)
return tool_name
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather see us returning the AgentTool which already has the name attached AFAIK

logger.warning("tool_name=<%s> | failed to update tool | %s", tool_name, e)
raise ValueError(f"Failed to update tool '{tool_name}': {str(e)}") from e

def delete_tool(self, tool_name: str) -> str:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that this class is named ToolRegistry perhaps we can name this as just delete? Same with the other {verb}_{noun} names?

# Register in dynamic tools if applicable
if tool.is_dynamic:
self.dynamic_tools[tool.tool_name] = tool
def list_tools(self) -> Dict[str, ToolSpec]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a little weird that a list method is returning a dictionary. Would this be better named as get_tool_specs or something?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants