Source code for tooluniverse.base_tool
from .utils import extract_function_call_json, evaluate_function_call
from .exceptions import (
ToolError,
ToolValidationError,
ToolAuthError,
ToolRateLimitError,
ToolUnavailableError,
ToolConfigError,
ToolDependencyError,
ToolServerError,
)
import json
from pathlib import Path
from typing import no_type_check, Optional, Dict, Any
import hashlib
import inspect
[docs]
class BaseTool:
STATIC_CACHE_VERSION = "1"
[docs]
def __init__(self, tool_config):
self.tool_config = self._apply_defaults(tool_config)
self._cached_version_hash: Optional[str] = None
[docs]
@classmethod
def get_default_config_file(cls):
"""
Get the path to the default configuration file for this tool type.
This method uses a robust path resolution strategy that works across
different installation scenarios:
1. Installed packages: Uses importlib.resources for proper package
resource access
2. Development mode: Falls back to file-based path resolution
3. Legacy Python: Handles importlib.resources and importlib_resources
Override this method in subclasses to specify a custom defaults file.
Returns
Path or resource object pointing to the defaults file
"""
tool_type = cls.__name__
# Use importlib.resources for robust path resolution across different
# installation methods
try:
import importlib.resources as pkg_resources
except ImportError:
# Fallback for Python < 3.9
import importlib_resources as pkg_resources
try:
# Try to use package resources first (works with installed
# packages). Use the newer files() API
data_files = pkg_resources.files("tooluniverse.data")
defaults_file = data_files / f"{tool_type.lower()}_defaults.json"
# For compatibility, convert to a regular Path if possible
if hasattr(defaults_file, "resolve"):
return defaults_file.resolve()
else:
# For older Python versions or special cases, return resource
# path
return defaults_file
except (FileNotFoundError, ModuleNotFoundError, AttributeError):
# Fallback to file-based path resolution for development/local use
current_dir = Path(__file__).parent
defaults_file = current_dir / "data" / f"{tool_type.lower()}_defaults.json"
return defaults_file
[docs]
@classmethod
def load_defaults_from_file(cls):
"""Load defaults from the configuration file"""
defaults_file = cls.get_default_config_file()
# Handle both regular Path objects and importlib resource objects
try:
# Check if it's a regular Path object
if hasattr(defaults_file, "exists") and not defaults_file.exists():
return {}
# Try to read the file (works for both Path and resource objects)
if hasattr(defaults_file, "read_text"):
# Resource object with read_text method
content = defaults_file.read_text(encoding="utf-8")
data = json.loads(content)
else:
# Regular file path
with open(defaults_file, "r", encoding="utf-8") as f:
data = json.load(f)
# Look for defaults under the tool type key
tool_type = cls.__name__
return data.get(f"{tool_type.lower()}_defaults", {})
except (FileNotFoundError, json.JSONDecodeError):
# File doesn't exist or invalid JSON, return empty defaults
return {}
except Exception as e:
print(f"Warning: Could not load defaults for {cls.__name__}: {e}")
return {}
[docs]
def _apply_defaults(self, tool_config):
"""Apply default configuration to the tool config"""
# Load defaults from file
defaults = self.load_defaults_from_file()
if not defaults:
# No defaults available, return original config
return tool_config
# Create merged configuration by starting with defaults
merged_config = defaults.copy()
# Override with tool-specific configuration
merged_config.update(tool_config)
return merged_config
[docs]
@no_type_check
def run(self, arguments=None, stream_callback=None, use_cache=False, validate=True):
"""Execute the tool.
The default BaseTool implementation accepts an optional arguments
mapping to align with most concrete tool implementations which expect
a dictionary of inputs.
Args:
arguments (dict, optional): Tool-specific arguments
stream_callback (callable, optional): Callback for streaming responses
use_cache (bool, optional): Whether result caching is enabled
validate (bool, optional): Whether parameter validation was performed
Note:
These additional parameters (stream_callback, use_cache, validate) are
passed from run_one_function() to provide context about the execution.
Tools can use these for optimization or special handling.
For backward compatibility, tools that don't accept these parameters
will still work - they will only receive the arguments parameter.
"""
[docs]
def check_function_call(self, function_call_json):
if isinstance(function_call_json, str):
function_call_json = extract_function_call_json(function_call_json)
if function_call_json is not None:
return evaluate_function_call(self.tool_config, function_call_json)
else:
return False, "Invalid JSON string of function call"
[docs]
def get_schema_const_operation(self) -> str:
"""Return the operation value from the tool's parameter schema, or empty string.
Checks `const` first (single fixed value), then falls back to the first
value in `enum` (single-value enum is equivalent to const).
"""
op_schema = (
self.tool_config.get("parameter", {})
.get("properties", {})
.get("operation", {})
)
const = op_schema.get("const", "")
if const:
return const
enum = op_schema.get("enum", [])
return enum[0] if enum else ""
[docs]
def get_required_parameters(self):
"""
Retrieve required parameters from the endpoint definition.
Returns
list: List of required parameters for the given endpoint.
"""
schema = self.tool_config.get("parameter", {})
required_params = schema.get("required", [])
return required_params
[docs]
@staticmethod
def _normalize_key(key: str) -> str:
"""Lowercase a parameter name and drop separators for fuzzy comparison."""
return "".join(ch for ch in key.lower() if ch.isalnum())
[docs]
@staticmethod
def _unknown_keys(arguments: Dict[str, Any], properties: Dict[str, Any]) -> list:
"""Return supplied argument names that are not declared in the schema.
Returns an empty list when the schema declares no properties, since
there is then nothing to compare against.
"""
if not properties:
return []
return [k for k in arguments if k not in properties]
[docs]
@classmethod
def _find_misspelled_key(
cls,
missing_prop: str,
arguments: Dict[str, Any],
properties: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
"""Find which supplied key was probably meant to be ``missing_prop``.
Only considers keys that are not themselves valid schema properties, so
a legitimately-supplied sibling parameter is never reported as a typo.
Matching widens in three stages: exact case-insensitive, then
separator-insensitive (``geneName`` -> ``gene_name``), then fuzzy.
"""
candidates = cls._unknown_keys(arguments, properties or {}) or list(arguments)
if not candidates:
return None
target = missing_prop.lower()
for key in candidates:
if key.lower() == target:
return key
target_norm = cls._normalize_key(missing_prop)
for key in candidates:
if cls._normalize_key(key) == target_norm:
return key
# Fuzzy fallback for ordinary typos ('acession' -> 'accession').
# The cutoff is deliberately high so that an unrelated parameter is
# not mislabelled as a misspelling of the missing one.
import difflib
norm_to_key = {cls._normalize_key(k): k for k in candidates}
match = difflib.get_close_matches(
target_norm, list(norm_to_key), n=1, cutoff=0.8
)
return norm_to_key[match[0]] if match else None
[docs]
def validate_parameters(self, arguments: Dict[str, Any]) -> Optional[ToolError]:
"""
Validate parameters against tool schema.
This method provides standard parameter validation using jsonschema.
Subclasses can override this method to implement custom validation
logic.
Args:
arguments: Dictionary of arguments to validate
Returns
ToolError if validation fails, None if validation passes
"""
schema = self.tool_config.get("parameter", {})
if not schema:
return None # No schema to validate against
try:
import jsonschema
except ImportError:
# jsonschema not available, skip validation
return None
try:
# Filter out internal control parameters before validation
# Only filter known internal parameters, not all underscore-prefixed params
# to allow optional streaming parameter _tooluniverse_stream
internal_params = {"ctx", "_tooluniverse_stream"}
filtered_arguments = {
k: v for k, v in arguments.items() if k not in internal_params
}
jsonschema.validate(filtered_arguments, schema)
return None
except jsonschema.ValidationError as e:
# Create a more agent-friendly error message
error_msg = f"Parameter validation failed for '{e.path[-1] if e.path else 'root'}': {e.message}"
# Add type hint if it's a type error
if e.validator == "type":
error_msg += (
f". Expected {e.validator_value}, got {type(e.instance).__name__}."
)
# Add allowed values if it's an enum error
if e.validator == "enum":
error_msg += f". Allowed values: {e.validator_value}."
# Feature-25A-03: when a required property is missing, check if the user
# provided a case-variant of it (e.g. kinase_id instead of kinase_ID).
# If so, surface a "Did you mean?" hint to help them fix the typo.
# Feature-R3-01: case-variants were the ONLY form matched, so the far
# more common real-world errors -- an ordinary typo ('acession'), or
# separator/camelCase drift ('geneName' for 'gene_name') -- produced a
# bare "'accession' is a required property" with no indication that the
# key the user actually passed was unrecognized. Widen the match to
# separator-insensitive and fuzzy comparison, and when nothing matches
# at all, name the unrecognized keys instead of staying silent.
if e.validator == "required" and isinstance(filtered_arguments, dict):
# e.message looks like: "'kinase_ID' is a required property"
# Extract the missing property name from the message.
import re as _re
_m = _re.match(r"'([^']+)' is a required property", e.message)
if _m:
missing_prop = _m.group(1)
wrong_key = self._find_misspelled_key(
missing_prop,
filtered_arguments,
properties=schema.get("properties", {}),
)
if wrong_key is not None:
error_msg += (
f" (you passed '{wrong_key}' — "
f"did you mean '{missing_prop}'?)"
)
else:
unknown = self._unknown_keys(
filtered_arguments, schema.get("properties", {})
)
if unknown:
error_msg += (
f" (unrecognized parameter(s): "
f"{', '.join(repr(k) for k in unknown)})"
)
return ToolValidationError(
error_msg,
details={
"validation_error": str(e),
"path": list(e.absolute_path) if e.absolute_path else [],
"schema": schema,
"parameter": str(e.path[-1]) if e.path else "root",
"expected": str(e.validator_value)
if hasattr(e, "validator_value")
else None,
},
)
except Exception as e:
return ToolValidationError(f"Validation error: {str(e)}")
# Maps keyword groups to (ToolError subclass, message prefix).
# Checked in order; first match wins.
_ERROR_CLASSIFICATION = [
(
{"auth", "unauthorized", "401", "403", "api key", "token"},
ToolAuthError,
"Authentication failed",
),
(
{"rate limit", "429", "quota", "limit exceeded"},
ToolRateLimitError,
"Rate limit exceeded",
),
(
{"unavailable", "timeout", "connection", "network", "not found", "404"},
ToolUnavailableError,
"Tool unavailable",
),
(
{"validation", "invalid", "schema", "parameter"},
ToolValidationError,
"Validation error",
),
({"config", "configuration", "setup"}, ToolConfigError, "Configuration error"),
(
{"import", "module", "dependency", "package"},
ToolDependencyError,
"Dependency error",
),
]
[docs]
def handle_error(self, exception: Exception) -> ToolError:
"""
Classify a raw exception into a structured ToolError.
This method provides standard error classification. Subclasses can
override this method to implement custom error handling logic.
Args:
exception: The raw exception to classify
Returns
Structured ToolError instance
"""
# ValueError always signals a caller-side input problem (not server error)
if isinstance(exception, ValueError):
return ToolValidationError(f"Validation error: {exception}")
# Feature-25A-01: for HTTP errors, include the response body so callers see
# the upstream API's actual message rather than a generic "Base API error".
response = getattr(exception, "response", None)
response_detail = ""
if response is not None:
try:
body = response.json()
# Surface common error fields used across APIs
for key in ("message", "error", "detail", "description", "reason"):
if key in body:
response_detail = f" — API said: {body[key]}"
break
else:
# Fall back to raw text (truncated to avoid noise)
text = response.text
if text:
response_detail = f" — API response: {text[:200]}"
except Exception:
text = getattr(response, "text", "")
if text:
response_detail = f" — API response: {text[:200]}"
error_str = str(exception).lower()
full_msg = f"{exception}{response_detail}"
for keywords, error_class, prefix in self._ERROR_CLASSIFICATION:
if any(kw in error_str for kw in keywords):
return error_class(f"{prefix}: {full_msg}")
return ToolServerError(f"Unexpected error: {full_msg}")
[docs]
def get_cache_key(self, arguments: Dict[str, Any]) -> str:
"""
Generate a cache key for this tool call.
This method provides standard cache key generation. Subclasses can
override this method to implement custom caching logic.
Args:
arguments: Dictionary of arguments for the tool call
Returns
String cache key
"""
# Include tool name and arguments in cache key
cache_data = {
"tool_name": self.tool_config.get("name", self.__class__.__name__),
"arguments": arguments,
}
serialized = json.dumps(cache_data, sort_keys=True)
return hashlib.md5(serialized.encode()).hexdigest()
[docs]
def supports_streaming(self) -> bool:
"""
Check if this tool supports streaming responses.
Returns
True if tool supports streaming, False otherwise
"""
return self.tool_config.get("supports_streaming", False)
[docs]
def supports_caching(self) -> bool:
"""
Check if this tool's results can be cached.
Returns
True if tool results can be cached, False otherwise
"""
return self.tool_config.get("cacheable", True)
[docs]
def get_batch_concurrency_limit(self) -> int:
"""Return maximum concurrent executions allowed during batch runs (0 = unlimited)."""
limit = self.tool_config.get("batch_max_concurrency")
if limit is None:
return 0
try:
parsed = int(limit)
except (TypeError, ValueError):
return 0
return max(0, parsed)
[docs]
def get_cache_namespace(self) -> str:
"""Return cache namespace identifier for this tool."""
return self.tool_config.get("name", self.__class__.__name__)
[docs]
def get_cache_version(self) -> str:
"""Return a stable cache version fingerprint for this tool."""
if self._cached_version_hash:
return self._cached_version_hash
hasher = hashlib.sha256()
hasher.update(self.STATIC_CACHE_VERSION.encode("utf-8"))
try:
source = inspect.getsource(self.__class__)
hasher.update(source.encode("utf-8"))
except (OSError, TypeError):
pass
try:
schema = json.dumps(self.tool_config.get("parameter", {}), sort_keys=True)
hasher.update(schema.encode("utf-8"))
except (TypeError, ValueError):
pass
self._cached_version_hash = hasher.hexdigest()[:16]
return self._cached_version_hash
[docs]
def get_cache_ttl(self, result: Any = None) -> Optional[int]:
"""Return TTL (seconds) for cached results; None means no expiration."""
ttl = self.tool_config.get("cache_ttl")
return int(ttl) if ttl is not None else None
[docs]
def get_tool_info(self) -> Dict[str, Any]:
"""
Get comprehensive information about this tool.
Returns
Dictionary containing tool metadata
"""
return {
"name": self.tool_config.get("name", self.__class__.__name__),
"description": self.tool_config.get("description", ""),
"supports_streaming": self.supports_streaming(),
"supports_caching": self.supports_caching(),
"required_parameters": self.get_required_parameters(),
"parameter_schema": self.tool_config.get("parameter", {}),
"tool_type": self.__class__.__name__,
}