- harness/es.py: append-only event store + проекции (образец — кооп-портал) - harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу - harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы - harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе (глобальный asyncio-рантайм Strands не переживает 2+ create_harness) - cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом) - 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
709 lines
29 KiB
Python
709 lines
29 KiB
Python
"""Tool registry.
|
|
|
|
This module provides the central registry for all tools available to the agent, including discovery, validation, and
|
|
invocation capabilities.
|
|
"""
|
|
|
|
import inspect
|
|
import logging
|
|
import os
|
|
import uuid
|
|
import warnings
|
|
from collections.abc import Iterable, Sequence
|
|
from os.path import expanduser
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
from typing_extensions import TypedDict
|
|
|
|
from .._async import run_async
|
|
from ..agent.base import AgentBase
|
|
from ..tools.decorator import DecoratedFunctionTool
|
|
from ..types.tools import AgentTool, ToolSpec
|
|
from . import ToolProvider
|
|
from .loader import _load_tool_module, load_tool_from_string, load_tools_from_module
|
|
from .tools import _COMPOSITION_KEYWORDS, PythonAgentTool, normalize_schema, normalize_tool_spec
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ToolRegistry:
|
|
"""Central registry for all tools available to the agent.
|
|
|
|
This class manages tool registration, validation, discovery, and invocation.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize the tool registry."""
|
|
self.registry: dict[str, AgentTool] = {}
|
|
self.dynamic_tools: dict[str, AgentTool] = {}
|
|
self.tool_config: dict[str, Any] | None = None
|
|
self._tool_providers: list[ToolProvider] = []
|
|
self._registry_id = str(uuid.uuid4())
|
|
|
|
def process_tools(self, tools: list[Any]) -> list[str]:
|
|
"""Process tools list.
|
|
|
|
Process list of tools that can contain local file path string, module import path string,
|
|
imported modules, @tool decorated functions, or instances of AgentTool.
|
|
|
|
Args:
|
|
tools: List of tool specifications. Can be:
|
|
|
|
1. Local file path to a module based tool: `./path/to/module/tool.py`
|
|
2. Module import path
|
|
|
|
2.1. Path to a module based tool: `strands_tools.file_read`
|
|
2.2. Path to a module with multiple AgentTool instances (@tool decorated):
|
|
`tests.fixtures.say_tool`
|
|
2.3. Path to a module and a specific function: `tests.fixtures.say_tool:say`
|
|
|
|
3. A module for a module based tool
|
|
4. Instances of AgentTool (@tool decorated functions)
|
|
5. Dictionaries with name/path keys (deprecated)
|
|
6. Agent instances with an ``as_tool()`` method (auto-wrapped)
|
|
|
|
|
|
Returns:
|
|
List of tool names that were processed.
|
|
"""
|
|
tool_names = []
|
|
|
|
def add_tool(tool: Any) -> None:
|
|
try:
|
|
# String based tool
|
|
# Can be a file path, a module path, or a module path with a targeted function. Examples:
|
|
# './path/to/tool.py'
|
|
# 'my.module.tool'
|
|
# 'my.module.tool:tool_name'
|
|
if isinstance(tool, str):
|
|
tools = load_tool_from_string(tool)
|
|
for a_tool in tools:
|
|
a_tool.mark_dynamic()
|
|
self.register_tool(a_tool)
|
|
tool_names.append(a_tool.tool_name)
|
|
|
|
# Dictionary with name and path
|
|
elif isinstance(tool, dict) and "name" in tool and "path" in tool:
|
|
tools = load_tool_from_string(tool["path"])
|
|
|
|
tool_found = False
|
|
for a_tool in tools:
|
|
if a_tool.tool_name == tool["name"]:
|
|
a_tool.mark_dynamic()
|
|
self.register_tool(a_tool)
|
|
tool_names.append(a_tool.tool_name)
|
|
tool_found = True
|
|
|
|
if not tool_found:
|
|
raise ValueError(f'Tool "{tool["name"]}" not found in "{tool["path"]}"')
|
|
|
|
# Dictionary with path only
|
|
elif isinstance(tool, dict) and "path" in tool:
|
|
tools = load_tool_from_string(tool["path"])
|
|
|
|
for a_tool in tools:
|
|
a_tool.mark_dynamic()
|
|
self.register_tool(a_tool)
|
|
tool_names.append(a_tool.tool_name)
|
|
|
|
# Imported Python module
|
|
elif hasattr(tool, "__file__") and inspect.ismodule(tool):
|
|
# Extract the tool name from the module name
|
|
module_tool_name = tool.__name__.split(".")[-1]
|
|
|
|
tools = load_tools_from_module(tool, module_tool_name)
|
|
for a_tool in tools:
|
|
self.register_tool(a_tool)
|
|
tool_names.append(a_tool.tool_name)
|
|
|
|
# Case 5: AgentTools (which also covers @tool)
|
|
elif isinstance(tool, AgentTool):
|
|
self.register_tool(tool)
|
|
tool_names.append(tool.tool_name)
|
|
|
|
# Case 6: Nested iterable (list, tuple, etc.) - add each sub-tool
|
|
elif isinstance(tool, Iterable) and not isinstance(tool, (str, bytes, bytearray)):
|
|
for t in tool:
|
|
add_tool(t)
|
|
|
|
# Case 5: ToolProvider
|
|
elif isinstance(tool, ToolProvider):
|
|
self._tool_providers.append(tool)
|
|
tool.add_consumer(self._registry_id)
|
|
|
|
async def get_tools() -> Sequence[AgentTool]:
|
|
return await tool.load_tools()
|
|
|
|
provider_tools = run_async(get_tools)
|
|
|
|
for provider_tool in provider_tools:
|
|
self.register_tool(provider_tool)
|
|
tool_names.append(provider_tool.tool_name)
|
|
# Agent instances - auto-wrap with .as_tool() for convenience
|
|
elif isinstance(tool, AgentBase) and hasattr(tool, "as_tool") and callable(tool.as_tool):
|
|
wrapped_tool = tool.as_tool()
|
|
self.register_tool(wrapped_tool)
|
|
tool_names.append(wrapped_tool.tool_name)
|
|
|
|
else:
|
|
logger.warning("tool=<%s> | unrecognized tool specification", tool)
|
|
|
|
except Exception as e:
|
|
exception_str = str(e)
|
|
logger.exception("tool_name=<%s> | failed to load tool", tool)
|
|
raise ValueError(f"Failed to load tool {tool}: {exception_str}") from e
|
|
|
|
for tool in tools:
|
|
add_tool(tool)
|
|
return tool_names
|
|
|
|
def load_tool_from_filepath(self, tool_name: str, tool_path: str) -> None:
|
|
"""DEPRECATED: Load a tool from a file path.
|
|
|
|
Args:
|
|
tool_name: Name of the tool.
|
|
tool_path: Path to the tool file.
|
|
|
|
Raises:
|
|
FileNotFoundError: If the tool file is not found.
|
|
ValueError: If the tool cannot be loaded.
|
|
"""
|
|
warnings.warn(
|
|
"load_tool_from_filepath is deprecated and will be removed in Strands SDK 2.0. "
|
|
"`process_tools` automatically handles loading tools from a filepath.",
|
|
DeprecationWarning,
|
|
stacklevel=2,
|
|
)
|
|
|
|
from .loader import ToolLoader
|
|
|
|
try:
|
|
tool_path = expanduser(tool_path)
|
|
if not os.path.exists(tool_path):
|
|
raise FileNotFoundError(f"Tool file not found: {tool_path}")
|
|
|
|
loaded_tools = ToolLoader.load_tools(tool_path, tool_name)
|
|
for t in loaded_tools:
|
|
t.mark_dynamic()
|
|
# Because we're explicitly registering the tool we don't need an allowlist
|
|
self.register_tool(t)
|
|
except Exception as e:
|
|
exception_str = str(e)
|
|
logger.exception("tool_name=<%s> | failed to load tool", tool_name)
|
|
raise ValueError(f"Failed to load tool {tool_name}: {exception_str}") from e
|
|
|
|
def get_all_tools_config(self) -> dict[str, Any]:
|
|
"""Dynamically generate tool configuration by combining built-in and dynamic tools.
|
|
|
|
Returns:
|
|
Dictionary containing all tool configurations.
|
|
"""
|
|
tool_config = {}
|
|
logger.debug("getting tool configurations")
|
|
|
|
# Add all registered tools
|
|
for tool_name, tool in self.registry.items():
|
|
# Make a deep copy to avoid modifying the original
|
|
spec = tool.tool_spec.copy()
|
|
try:
|
|
# Normalize the schema before validation
|
|
spec = normalize_tool_spec(spec)
|
|
self.validate_tool_spec(spec)
|
|
tool_config[tool_name] = spec
|
|
logger.debug("tool_name=<%s> | loaded tool config", tool_name)
|
|
except (ValueError, RecursionError) as e:
|
|
logger.warning("tool_name=<%s> | spec validation failed | %s", tool_name, e)
|
|
|
|
# Add any dynamic tools
|
|
for tool_name, tool in self.dynamic_tools.items():
|
|
if tool_name not in tool_config:
|
|
# Make a deep copy to avoid modifying the original
|
|
spec = tool.tool_spec.copy()
|
|
try:
|
|
# Normalize the schema before validation
|
|
spec = normalize_tool_spec(spec)
|
|
self.validate_tool_spec(spec)
|
|
tool_config[tool_name] = spec
|
|
logger.debug("tool_name=<%s> | loaded dynamic tool config", tool_name)
|
|
except (ValueError, RecursionError) as e:
|
|
logger.warning("tool_name=<%s> | dynamic tool spec validation failed | %s", tool_name, e)
|
|
|
|
logger.debug("tool_count=<%s> | tools configured", len(tool_config))
|
|
return tool_config
|
|
|
|
# mypy has problems converting between DecoratedFunctionTool <-> AgentTool
|
|
def register_tool(self, tool: AgentTool) -> None:
|
|
"""Register a tool function with the given name.
|
|
|
|
Args:
|
|
tool: The tool to register.
|
|
"""
|
|
logger.debug(
|
|
"tool_name=<%s>, tool_type=<%s>, is_dynamic=<%s> | registering tool",
|
|
tool.tool_name,
|
|
tool.tool_type,
|
|
tool.is_dynamic,
|
|
)
|
|
|
|
# Check duplicate tool name, throw on duplicate tool names except if hot_reloading is enabled
|
|
if tool.tool_name in self.registry and not tool.supports_hot_reload:
|
|
raise ValueError(
|
|
f"Tool name '{tool.tool_name}' already exists. Cannot register tools with exact same name."
|
|
)
|
|
|
|
# Check for normalized name conflicts (- vs _)
|
|
if self.registry.get(tool.tool_name) is None:
|
|
normalized_name = tool.tool_name.replace("-", "_")
|
|
|
|
matching_tools = [
|
|
tool_name
|
|
for (tool_name, tool) in self.registry.items()
|
|
if tool_name.replace("-", "_") == normalized_name
|
|
]
|
|
|
|
if matching_tools:
|
|
raise ValueError(
|
|
f"Tool name '{tool.tool_name}' already exists as '{matching_tools[0]}'."
|
|
" Cannot add a duplicate tool which differs by a '-' or '_'"
|
|
)
|
|
|
|
# Register in main registry
|
|
self.registry[tool.tool_name] = tool
|
|
|
|
# Register in dynamic tools if applicable
|
|
if tool.is_dynamic:
|
|
self.dynamic_tools[tool.tool_name] = tool
|
|
|
|
if not tool.supports_hot_reload:
|
|
logger.debug("tool_name=<%s>, tool_type=<%s> | skipping hot reloading", tool.tool_name, tool.tool_type)
|
|
return
|
|
|
|
logger.debug(
|
|
"tool_name=<%s>, tool_registry=<%s>, dynamic_tools=<%s> | tool registered",
|
|
tool.tool_name,
|
|
list(self.registry.keys()),
|
|
list(self.dynamic_tools.keys()),
|
|
)
|
|
|
|
def replace(self, new_tool: AgentTool) -> None:
|
|
"""Replace an existing tool with a new implementation.
|
|
|
|
This performs a swap of the tool implementation in the registry.
|
|
The replacement takes effect on the next agent invocation.
|
|
|
|
Args:
|
|
new_tool: New tool implementation. Its name must match the tool being replaced.
|
|
|
|
Raises:
|
|
ValueError: If the tool doesn't exist.
|
|
"""
|
|
tool_name = new_tool.tool_name
|
|
|
|
if tool_name not in self.registry:
|
|
raise ValueError(f"Cannot replace tool '{tool_name}' - tool does not exist")
|
|
|
|
# Update main registry
|
|
self.registry[tool_name] = new_tool
|
|
|
|
# Update dynamic_tools to match new tool's dynamic status
|
|
if new_tool.is_dynamic:
|
|
self.dynamic_tools[tool_name] = new_tool
|
|
elif tool_name in self.dynamic_tools:
|
|
del self.dynamic_tools[tool_name]
|
|
|
|
def get_tools_dirs(self) -> list[Path]:
|
|
"""Get all tool directory paths.
|
|
|
|
Returns:
|
|
A list of Path objects for current working directory's "./tools/".
|
|
"""
|
|
# Current working directory's tools directory
|
|
cwd_tools_dir = Path.cwd() / "tools"
|
|
|
|
# Return all directories that exist
|
|
tool_dirs = []
|
|
for directory in [cwd_tools_dir]:
|
|
if directory.exists() and directory.is_dir():
|
|
tool_dirs.append(directory)
|
|
logger.debug("tools_dir=<%s> | found tools directory", directory)
|
|
else:
|
|
logger.debug("tools_dir=<%s> | tools directory not found", directory)
|
|
|
|
return tool_dirs
|
|
|
|
def discover_tool_modules(self) -> dict[str, Path]:
|
|
"""Discover available tool modules in all tools directories.
|
|
|
|
Returns:
|
|
Dictionary mapping tool names to their full paths.
|
|
"""
|
|
tool_modules = {}
|
|
tools_dirs = self.get_tools_dirs()
|
|
|
|
for tools_dir in tools_dirs:
|
|
logger.debug("tools_dir=<%s> | scanning", tools_dir)
|
|
|
|
# Find Python tools
|
|
for extension in ["*.py"]:
|
|
for item in tools_dir.glob(extension):
|
|
if item.is_file() and not item.name.startswith("__"):
|
|
module_name = item.stem
|
|
# If tool already exists, newer paths take precedence
|
|
if module_name in tool_modules:
|
|
logger.debug("tools_dir=<%s>, module_name=<%s> | tool overridden", tools_dir, module_name)
|
|
tool_modules[module_name] = item
|
|
|
|
logger.debug("tool_modules=<%s> | discovered", list(tool_modules.keys()))
|
|
return tool_modules
|
|
|
|
def reload_tool(self, tool_name: str) -> None:
|
|
"""Reload a specific tool module.
|
|
|
|
Args:
|
|
tool_name: Name of the tool to reload.
|
|
|
|
Raises:
|
|
FileNotFoundError: If the tool file cannot be found.
|
|
ImportError: If there are issues importing the tool module.
|
|
ValueError: If the tool specification is invalid or required components are missing.
|
|
Exception: For other errors during tool reloading.
|
|
"""
|
|
try:
|
|
# Check for tool file
|
|
logger.debug("tool_name=<%s> | searching directories for tool", tool_name)
|
|
tools_dirs = self.get_tools_dirs()
|
|
tool_path = None
|
|
|
|
# Search for the tool file in all tool directories
|
|
for tools_dir in tools_dirs:
|
|
temp_path = tools_dir / f"{tool_name}.py"
|
|
if temp_path.exists():
|
|
tool_path = temp_path
|
|
break
|
|
|
|
if not tool_path:
|
|
raise FileNotFoundError(f"No tool file found for: {tool_name}")
|
|
|
|
logger.debug("tool_name=<%s> | reloading tool", tool_name)
|
|
|
|
# Load the module under a namespaced key so a tool file does not clobber an
|
|
# identically named entry in sys.modules (e.g. a tool named "json"), with the
|
|
# tool directory scoped onto sys.path only while the module executes.
|
|
module = _load_tool_module(tool_name, str(tool_path))
|
|
|
|
# Look for function-based tools first
|
|
try:
|
|
function_tools = self._scan_module_for_tools(module)
|
|
|
|
if function_tools:
|
|
for function_tool in function_tools:
|
|
# Register the function-based tool
|
|
self.register_tool(function_tool)
|
|
|
|
# Update tool configuration if available
|
|
if self.tool_config is not None:
|
|
self._update_tool_config(self.tool_config, {"spec": function_tool.tool_spec})
|
|
|
|
logger.debug("tool_name=<%s> | successfully reloaded function-based tool from module", tool_name)
|
|
return
|
|
except ImportError:
|
|
logger.debug("function tool loader not available | falling back to traditional tools")
|
|
|
|
# Fall back to traditional module-level tools
|
|
if not hasattr(module, "TOOL_SPEC"):
|
|
raise ValueError(
|
|
f"Tool {tool_name} is missing TOOL_SPEC (neither at module level nor as a decorated function)"
|
|
)
|
|
|
|
expected_func_name = tool_name
|
|
if not hasattr(module, expected_func_name):
|
|
raise ValueError(f"Tool {tool_name} is missing {expected_func_name} function")
|
|
|
|
tool_function = getattr(module, expected_func_name)
|
|
if not callable(tool_function):
|
|
raise ValueError(f"Tool {tool_name} function is not callable")
|
|
|
|
# Validate tool spec
|
|
self.validate_tool_spec(module.TOOL_SPEC)
|
|
|
|
new_tool = PythonAgentTool(tool_name, module.TOOL_SPEC, tool_function)
|
|
|
|
# Register the tool
|
|
self.register_tool(new_tool)
|
|
|
|
# Update tool configuration if available
|
|
if self.tool_config is not None:
|
|
self._update_tool_config(self.tool_config, {"spec": module.TOOL_SPEC})
|
|
logger.debug("tool_name=<%s> | successfully reloaded tool", tool_name)
|
|
|
|
except Exception:
|
|
logger.exception("tool_name=<%s> | failed to reload tool", tool_name)
|
|
raise
|
|
|
|
def initialize_tools(self, load_tools_from_directory: bool = False) -> None:
|
|
"""Initialize all tools by discovering and loading them dynamically from all tool directories.
|
|
|
|
Args:
|
|
load_tools_from_directory: Whether to reload tools if changes are made at runtime.
|
|
"""
|
|
self.tool_config = None
|
|
|
|
# Then discover and load other tools
|
|
tool_modules = self.discover_tool_modules()
|
|
successful_loads = 0
|
|
total_tools = len(tool_modules)
|
|
tool_import_errors = {}
|
|
|
|
# Process Python tools
|
|
for tool_name, tool_path in tool_modules.items():
|
|
if tool_name in ["__init__"]:
|
|
continue
|
|
|
|
if not load_tools_from_directory:
|
|
continue
|
|
|
|
try:
|
|
# Load the module under a namespaced key so a tool file does not clobber an
|
|
# identically named entry in sys.modules (e.g. a tool named "json"), with the
|
|
# tool directory scoped onto sys.path only while the module executes.
|
|
module = _load_tool_module(tool_name, str(tool_path))
|
|
|
|
# Process Python tool
|
|
if tool_path.suffix == ".py":
|
|
# Check for decorated function tools first
|
|
try:
|
|
function_tools = self._scan_module_for_tools(module)
|
|
|
|
if function_tools:
|
|
for function_tool in function_tools:
|
|
self.register_tool(function_tool)
|
|
successful_loads += 1
|
|
else:
|
|
# Fall back to traditional tools
|
|
# Check for expected tool function
|
|
expected_func_name = tool_name
|
|
if hasattr(module, expected_func_name):
|
|
tool_function = getattr(module, expected_func_name)
|
|
if not callable(tool_function):
|
|
logger.warning(
|
|
"tool_name=<%s> | tool function exists but is not callable", tool_name
|
|
)
|
|
continue
|
|
|
|
# Validate tool spec before registering
|
|
if not hasattr(module, "TOOL_SPEC"):
|
|
logger.warning("tool_name=<%s> | tool is missing TOOL_SPEC | skipping", tool_name)
|
|
continue
|
|
|
|
try:
|
|
self.validate_tool_spec(module.TOOL_SPEC)
|
|
except ValueError as e:
|
|
logger.warning("tool_name=<%s> | tool spec validation failed | %s", tool_name, e)
|
|
continue
|
|
|
|
tool_spec = module.TOOL_SPEC
|
|
tool = PythonAgentTool(tool_name, tool_spec, tool_function)
|
|
self.register_tool(tool)
|
|
successful_loads += 1
|
|
|
|
else:
|
|
logger.warning("tool_name=<%s> | tool function missing", tool_name)
|
|
except ImportError:
|
|
# Function tool loader not available, fall back to traditional tools
|
|
# Check for expected tool function
|
|
expected_func_name = tool_name
|
|
if hasattr(module, expected_func_name):
|
|
tool_function = getattr(module, expected_func_name)
|
|
if not callable(tool_function):
|
|
logger.warning("tool_name=<%s> | tool function exists but is not callable", tool_name)
|
|
continue
|
|
|
|
# Validate tool spec before registering
|
|
if not hasattr(module, "TOOL_SPEC"):
|
|
logger.warning("tool_name=<%s> | tool is missing TOOL_SPEC | skipping", tool_name)
|
|
continue
|
|
|
|
try:
|
|
self.validate_tool_spec(module.TOOL_SPEC)
|
|
except ValueError as e:
|
|
logger.warning("tool_name=<%s> | tool spec validation failed | %s", tool_name, e)
|
|
continue
|
|
|
|
tool_spec = module.TOOL_SPEC
|
|
tool = PythonAgentTool(tool_name, tool_spec, tool_function)
|
|
self.register_tool(tool)
|
|
successful_loads += 1
|
|
|
|
else:
|
|
logger.warning("tool_name=<%s> | tool function missing", tool_name)
|
|
|
|
except Exception as e:
|
|
logger.warning("tool_name=<%s> | failed to load tool | %s", tool_name, e)
|
|
tool_import_errors[tool_name] = str(e)
|
|
|
|
# Log summary
|
|
logger.debug("tool_count=<%d>, success_count=<%d> | finished loading tools", total_tools, successful_loads)
|
|
if tool_import_errors:
|
|
for tool_name, error in tool_import_errors.items():
|
|
logger.debug("tool_name=<%s> | import error | %s", tool_name, error)
|
|
|
|
def get_all_tool_specs(self) -> list[ToolSpec]:
|
|
"""Get all the tool specs for all tools in this registry..
|
|
|
|
Returns:
|
|
A list of ToolSpecs.
|
|
"""
|
|
all_tools = self.get_all_tools_config()
|
|
tools: list[ToolSpec] = [tool_spec for tool_spec in all_tools.values()]
|
|
return tools
|
|
|
|
def register_dynamic_tool(self, tool: AgentTool) -> None:
|
|
"""Register a tool dynamically for temporary use.
|
|
|
|
Args:
|
|
tool: The tool to register dynamically
|
|
|
|
Raises:
|
|
ValueError: If a tool with this name already exists
|
|
"""
|
|
if tool.tool_name in self.registry or tool.tool_name in self.dynamic_tools:
|
|
raise ValueError(f"Tool '{tool.tool_name}' already exists")
|
|
|
|
self.dynamic_tools[tool.tool_name] = tool
|
|
logger.debug("Registered dynamic tool: %s", tool.tool_name)
|
|
|
|
def validate_tool_spec(self, tool_spec: ToolSpec) -> None:
|
|
"""Validate tool specification against required schema.
|
|
|
|
Args:
|
|
tool_spec: Tool specification to validate.
|
|
|
|
Raises:
|
|
ValueError: If the specification is invalid.
|
|
"""
|
|
required_fields = ["name", "description"]
|
|
missing_fields = [field for field in required_fields if field not in tool_spec]
|
|
if missing_fields:
|
|
raise ValueError(f"Missing required fields in tool spec: {', '.join(missing_fields)}")
|
|
|
|
if "json" not in tool_spec["inputSchema"]:
|
|
# Convert direct schema to proper format
|
|
json_schema = normalize_schema(tool_spec["inputSchema"])
|
|
tool_spec["inputSchema"] = {"json": json_schema}
|
|
return
|
|
|
|
# Validate json schema fields
|
|
json_schema = tool_spec["inputSchema"]["json"]
|
|
|
|
# Ensure schema has required fields
|
|
if "type" not in json_schema:
|
|
json_schema["type"] = "object"
|
|
if "properties" not in json_schema:
|
|
json_schema["properties"] = {}
|
|
if "required" not in json_schema:
|
|
json_schema["required"] = []
|
|
|
|
# Validate property definitions
|
|
for prop_name, prop_def in json_schema.get("properties", {}).items():
|
|
if not isinstance(prop_def, dict):
|
|
json_schema["properties"][prop_name] = {
|
|
"type": "string",
|
|
"description": f"Property {prop_name}",
|
|
}
|
|
continue
|
|
|
|
# It is expected that type and description are already included in referenced $def.
|
|
if "$ref" in prop_def:
|
|
continue
|
|
|
|
has_composition = any(kw in prop_def for kw in _COMPOSITION_KEYWORDS)
|
|
if "type" not in prop_def and not has_composition:
|
|
prop_def["type"] = "string"
|
|
if "description" not in prop_def:
|
|
prop_def["description"] = f"Property {prop_name}"
|
|
|
|
class NewToolDict(TypedDict):
|
|
"""Dictionary type for adding or updating a tool in the configuration.
|
|
|
|
Attributes:
|
|
spec: The tool specification that defines the tool's interface and behavior.
|
|
"""
|
|
|
|
spec: ToolSpec
|
|
|
|
def _update_tool_config(self, tool_config: dict[str, Any], new_tool: NewToolDict) -> None:
|
|
"""Update tool configuration with a new tool.
|
|
|
|
Args:
|
|
tool_config: The current tool configuration dictionary.
|
|
new_tool: The new tool to add/update.
|
|
|
|
Raises:
|
|
ValueError: If the new tool spec is invalid.
|
|
"""
|
|
if not new_tool.get("spec"):
|
|
raise ValueError("Invalid tool format - missing spec")
|
|
|
|
# Validate tool spec before updating
|
|
try:
|
|
self.validate_tool_spec(new_tool["spec"])
|
|
except ValueError as e:
|
|
raise ValueError(f"Tool specification validation failed: {str(e)}") from e
|
|
|
|
new_tool_name = new_tool["spec"]["name"]
|
|
existing_tool_idx = None
|
|
|
|
# Find if tool already exists
|
|
for idx, tool_entry in enumerate(tool_config["tools"]):
|
|
if tool_entry["toolSpec"]["name"] == new_tool_name:
|
|
existing_tool_idx = idx
|
|
break
|
|
|
|
# Update existing tool or add new one
|
|
new_tool_entry = {"toolSpec": new_tool["spec"]}
|
|
if existing_tool_idx is not None:
|
|
tool_config["tools"][existing_tool_idx] = new_tool_entry
|
|
logger.debug("tool_name=<%s> | updated existing tool", new_tool_name)
|
|
else:
|
|
tool_config["tools"].append(new_tool_entry)
|
|
logger.debug("tool_name=<%s> | added new tool", new_tool_name)
|
|
|
|
def _scan_module_for_tools(self, module: Any) -> list[AgentTool]:
|
|
"""Scan a module for function-based tools.
|
|
|
|
Args:
|
|
module: The module to scan.
|
|
|
|
Returns:
|
|
List of FunctionTool instances found in the module.
|
|
"""
|
|
tools: list[AgentTool] = []
|
|
|
|
for name, obj in inspect.getmembers(module):
|
|
if isinstance(obj, DecoratedFunctionTool):
|
|
# Create a function tool with correct name
|
|
try:
|
|
# Cast as AgentTool for mypy
|
|
tools.append(cast(AgentTool, obj))
|
|
except Exception as e:
|
|
logger.warning("tool_name=<%s> | failed to create function tool | %s", name, e)
|
|
|
|
return tools
|
|
|
|
def cleanup(self, **kwargs: Any) -> None:
|
|
"""Synchronously clean up all tool providers in this registry."""
|
|
# Attempt cleanup of all providers even if one fails to minimize resource leakage
|
|
exceptions = []
|
|
for provider in self._tool_providers:
|
|
try:
|
|
provider.remove_consumer(self._registry_id)
|
|
logger.debug("provider=<%s> | removed provider consumer", type(provider).__name__)
|
|
except Exception as e:
|
|
exceptions.append(e)
|
|
logger.error(
|
|
"provider=<%s>, error=<%s> | failed to remove provider consumer", type(provider).__name__, e
|
|
)
|
|
|
|
if exceptions:
|
|
raise exceptions[0]
|