Files
drug-discovery-prompts/upstream/mims-harvard-ToolUniverse/skills/tooluniverse-custom-tool/references/python-tool.md

22 KiB
Raw Permalink Blame History

title, task, lineage_type, upstream_source, upstream_sha, imported_at, prompt_class, upstream_changes, author, validated
title task lineage_type upstream_source upstream_sha imported_at prompt_class upstream_changes author validated
Python Tool Reference import https://github.com/mims-harvard/ToolUniverse/blob/e2520a96/skills/tooluniverse-custom-tool/references/python-tool.md e2520a96 2026-06-26 prompt accepted upstream false

Python Tool Reference

There are two Python class patterns depending on where the tool lives.


Pattern 1 — Workspace tool (.tooluniverse/tools/)

Use this for local/private tools dropped into a workspace directory.

from tooluniverse.tool_registry import register_tool

@register_tool
class MyAPI_search:
    name = "MyAPI_search"
    description = "Search my internal database. Returns matching records with id, title, and score."
    input_schema = {
        "type": "object",
        "properties": {
            "q": {"type": "string", "description": "Search query"},
            "limit": {"type": "integer", "description": "Max results (default 10)"}
        },
        "required": ["q"]
    }

    def run(self, q: str, limit: int = 10) -> dict:
        import requests
        resp = requests.get(
            "https://my-api.example.com/search",
            params={"q": q, "limit": limit},
            timeout=30,
        )
        resp.raise_for_status()
        return {"status": "success", "data": resp.json()}

Key points:

  • @register_tool with no argument — the class name is used as the tool name
  • run(self, **named_params) — ToolUniverse unpacks the validated arguments as keyword args
  • name, description, input_schema are class attributes (not __init__)

Pattern 2 — Plugin package tool

Use this for tools in a pip-installable package (e.g. tooluniverse-circuit).

import requests
from typing import Dict, Any
from tooluniverse.base_tool import BaseTool
from tooluniverse.tool_registry import register_tool

@register_tool("MyAPITool")
class MyAPITool(BaseTool):
    """Tool description here (used as docstring, not as the LLM-facing description)."""

    def __init__(self, tool_config: Dict[str, Any]):
        super().__init__(tool_config)
        self.timeout = tool_config.get("timeout", 30)
        fields = tool_config.get("fields", {})
        self.operation = fields.get("operation", "search")

    def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        query = arguments.get("query", "")
        if not query:
            return {"error": "query parameter is required"}
        try:
            resp = requests.get(
                "https://my-api.example.com/search",
                params={"q": query},
                timeout=self.timeout,
            )
            resp.raise_for_status()
            return {"status": "success", "data": resp.json()}
        except requests.exceptions.RequestException as e:
            return {"error": str(e)}

Key differences from Pattern 1:

  • @register_tool("ClassName") takes the class name as a string argument
  • Inherits from BaseTool (from tooluniverse.base_tool import BaseTool)
  • __init__ receives tool_config dict — call super().__init__(tool_config) first
  • run(self, arguments: Dict) receives all arguments in one dict — use .get() to extract
  • The LLM-facing name and description come from the JSON config file, not the class
  • fields in the config can pass routing info to __init__ (commonly "operation")

Multiple tools from one class (fields.operation pattern)

One Python class can back multiple JSON-configured tools. Use "fields": {"operation": "..."} in the JSON config and self.operation in __init__ to dispatch:

@register_tool("MyAPITool")
class MyAPITool(BaseTool):
    def __init__(self, tool_config: Dict[str, Any]):
        super().__init__(tool_config)
        self.operation = tool_config.get("fields", {}).get("operation", "search")

    def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        if self.operation == "search":
            return self._search(arguments)
        elif self.operation == "list":
            return self._list(arguments)
        return {"error": f"Unknown operation: {self.operation}"}

JSON configs for the two tools both set "type": "MyAPITool" but different "fields":

[
  { "name": "MyAPI_search", "type": "MyAPITool", "fields": {"operation": "search"}, ... },
  { "name": "MyAPI_list",   "type": "MyAPITool", "fields": {"operation": "list"},   ... }
]

Each JSON entry becomes a separate tool in ToolUniverse with its own name, description, and parameters.


Required elements (both patterns)

Element Pattern 1 (workspace) Pattern 2 (plugin)
Decorator @register_tool @register_tool("ClassName")
Base class plain class BaseTool
Name/description class attributes JSON config file
run signature run(self, param1, param2=...) run(self, arguments: Dict)

Return format

Always return a dict. Conventions:

# Success — data can be a list or a dict
return {"status": "success", "data": result}

# Success with extra context (source URL, total count, applied filters, etc.)
return {
    "status": "success",
    "data": result,
    "metadata": {
        "source": "My API (api.example.com)",
        "query": query,
        "total_results": len(result),
    },
}

# Error — return, don't raise
return {"status": "error", "message": "Record not found"}

# Plugin packages sometimes use "error" key directly (no "status"):
return {"error": "Record not found"}

tu test validates result["data"] against return_schema (only when status == "success"). The metadata key is ignored by schema validation — it is purely informational for the caller. Make sure the return_schema top-level type matches what you put in data:

  • List of results → "type": "array"
  • Single record lookup → "type": "object"

input_schema patterns (Pattern 1 / workspace)

input_schema = {
    "type": "object",
    "properties": {
        # Required string
        "gene_id": {"type": "string", "description": "Ensembl gene ID (e.g. ENSG00000139618)"},

        # Optional string (include "null" in type, omit from required)
        "species": {"type": ["string", "null"], "description": "Species name (default: human)"},

        # Optional integer
        "limit": {"type": ["integer", "null"], "description": "Max results (default 10)"},

        # Enum
        "format": {
            "type": "string",
            "enum": ["json", "csv", "tsv"],
            "description": "Output format"
        },
    },
    "required": ["gene_id"]
}

If a tool has NO required parameters (all params are optional), use "required": [] — do not omit the key entirely:

input_schema = {
    "type": "object",
    "properties": {
        "query": {"type": ["string", "null"], "description": "Optional search filter"},
        "limit": {"type": ["integer", "null"], "description": "Max results (default 50)"},
    },
    "required": []
}

For plugin packages, the schema is in the JSON config file under "parameter", not in the class.


In-memory caching (for large one-time fetches)

If your tool fetches a large index that rarely changes (e.g. a full catalog or reference dataset), cache it at module level so subsequent calls within the same process are instant:

from typing import Optional, List, Dict, Any

_cache: Optional[List[Dict[str, Any]]] = None

def _fetch_data(timeout: int = 30) -> List[Dict[str, Any]]:
    global _cache
    if _cache is None:
        import requests
        resp = requests.get("https://api.example.com/full-index", timeout=timeout)
        resp.raise_for_status()
        _cache = resp.json().get("items", [])
    return _cache

@register_tool("MyCachingTool")
class MyCachingTool(BaseTool):
    def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        items = _fetch_data(self.timeout)
        # filter/search locally ...
        return {"status": "success", "data": results}

This avoids re-downloading on every tu test run and makes local search very fast.


Using environment variables

import os

def run(self, q: str) -> dict:
    api_key = os.environ.get("MY_API_KEY")
    if not api_key:
        return {"status": "error", "message": "MY_API_KEY not set in .tooluniverse/.env"}
    # ...

Set the value in .tooluniverse/.env (workspace) or export it in the shell (plugin packages).


Multiple tools in one file

from tooluniverse.tool_registry import register_tool

@register_tool
class MyAPI_search:
    name = "MyAPI_search"
    description = "..."
    input_schema = { ... }
    def run(self, q: str) -> dict: ...

@register_tool
class MyAPI_get_record:
    name = "MyAPI_get_record"
    description = "..."
    input_schema = { ... }
    def run(self, record_id: str) -> dict: ...

Tagging for category search (Pattern 1)

Add a category class attribute to make the tool discoverable by tu list --category:

@register_tool
class MyAPI_search:
    name = "MyAPI_search"
    category = ["my_domain", "search"]
    description = "..."
    ...

Error handling pattern

def run(self, q: str) -> dict:
    import requests
    try:
        resp = requests.get("https://api.example.com/search", params={"q": q}, timeout=30)
        resp.raise_for_status()
        return {"status": "success", "data": resp.json()}
    except requests.exceptions.Timeout:
        return {"status": "error", "message": "Request timed out"}
    except requests.exceptions.HTTPError as e:
        return {"status": "error", "message": f"HTTP {e.response.status_code}: {e.response.text}"}
    except Exception as e:
        return {"status": "error", "message": str(e)}

Return schema for tools with multiple output shapes

When a single tool returns different fields depending on inputs (e.g., an RC filter returns cutoff_frequency_Hz but an LC filter returns resonant_frequency_Hz), use a loose schema that only requires the fields common to all paths. Do not require fields that are absent in some cases — schema validation will fail for those code paths even when the tool is correct:

# In JSON config: only require fields that are always present
"return_schema": {
    "type": "object",
    "properties": {
        "topology": {"type": "string"},
        "formula":  {"type": "string"}
    }
    # No "required" list — or only fields that always appear
}

This is better than oneOf for tools that are one class with multiple dispatch paths, because oneOf requires exactly one schema to match which can be fragile.


Pure-computation tools (no HTTP)

Tools that perform local calculations (unit converters, color codes, checksum calculators, etc.) follow the same pattern but skip the HTTP layer entirely. Move the logic into standalone helper functions and call them from run():

from tooluniverse.base_tool import BaseTool
from tooluniverse.tool_registry import register_tool
from typing import Dict, Any

def _compute(value: float) -> Dict[str, Any]:
    """Pure computation — no imports needed at module level."""
    result = value * 2  # replace with real logic
    return {"input": value, "output": result}


@register_tool("MyCalculatorTool")
class MyCalculatorTool(BaseTool):
    def __init__(self, tool_config: Dict[str, Any]):
        super().__init__(tool_config)
        self.operation = tool_config.get("fields", {}).get("operation", "compute")

    def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        try:
            value = float(arguments.get("value") or 0)
            result = _compute(value)
            return {
                "status": "success",
                "data": result,
                "metadata": {"note": "Runs offline — no network request."},
            }
        except Exception as e:
            return {"error": str(e)}

Key points for pure-computation tools:

  • No requests import needed — the tool is always available even without internet
  • Put logic in module-level helper functions; keep run() thin
  • State the offline nature in the description and metadata.note
  • No timeout parameter needed in __init__

SI prefix formatting helper

A reusable pattern for formatting numbers with SI prefixes (Ω, V, A, F, etc.). Always return the prefix without a trailing space so callers can append the unit directly ("4.7k" + "Ω" → "4.7kΩ", not "4.7k Ω"):

def _fmt_si(value: float) -> str:
    """Return value with SI prefix, no unit. Append unit in caller."""
    abs_v = abs(value)
    if abs_v == 0:
        return "0"
    if abs_v >= 1e9:
        return f"{value / 1e9:.4g}G"
    if abs_v >= 1e6:
        return f"{value / 1e6:.4g}M"
    if abs_v >= 1e3:
        return f"{value / 1e3:.4g}k"
    if abs_v >= 1:
        return f"{value:.4g}"
    if abs_v >= 1e-3:
        return f"{value * 1e3:.4g}m"
    if abs_v >= 1e-6:
        return f"{value * 1e6:.4g}µ"
    if abs_v >= 1e-9:
        return f"{value * 1e9:.4g}n"
    return f"{value * 1e12:.4g}p"

# Usage:
formatted = _fmt_si(4700) + "Ω"   # "4.7kΩ"
formatted = _fmt_si(0.02) + "A"    # "20mA"
formatted = _fmt_si(0.000047) + "F" # "47µF"

The :.4g format gives 4 significant figures and drops trailing zeros automatically (4.700 → 4.7, 100.0 → 100).

Gotcha — never pass a pre-scaled unit to a two-argument _fmt_si(value, unit) variant. Some existing circuit tools use a variant that takes the unit as a second argument and appends it after the SI prefix. This only works correctly when the input value is in the base SI unit (meters, ohms, volts, amps, farads, hertz). If you pass an already-scaled value (e.g. width in mm) or a compound unit (e.g. "mΩ/m"), you will get double-prefixed output:

# WRONG — value is in mm, not metres; unit already contains "m"
_fmt_si(0.7814, "mm")  # → "781.4 mmm"   (triple-m!)

# CORRECT — value is in base SI unit (metres)
_fmt_si(0.0007814, "m")  # → "781.4 µm"

# CORRECT for non-SI-scalable compound units — just format directly
f"{resistance_mohm_per_m:.4g} mΩ/m"   # → "667.5 mΩ/m"

The unit-free variant above (returning only the prefixed number) is safer because the caller always provides the unit explicitly at the call site, making the scale clear.


Tools with no required parameters

When all parameters are optional (e.g., a "get current position" tool that takes no input), set "required": [] in the JSON config and add {} as a test example:

{
  "parameter": {
    "type": "object",
    "properties": {},
    "required": []
  },
  "test_examples": [{}]
}

tu test will call the tool with an empty dict {} and validate the result normally.

Gotcha — "required": [] does not mean "no runtime requirements". Many calculator tools set "required": [] in the JSON schema (to avoid schema validation errors when callers omit optional parameters) but still need certain params at runtime depending on the operation. Check for missing required parameters in run() and return a clear error:

def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
    alpha = arguments.get("switching_activity")
    cap_F = arguments.get("capacitance_F")
    missing = [n for n, v in [("switching_activity", alpha), ("capacitance_F", cap_F)]
               if v is None]
    if missing:
        return {"status": "error",
                "message": f"Missing required parameters: {missing}"}

Handling exponential computations safely

For tools like metastability calculators that use e^x with very large exponents (e.g., x = T_res / τ can be 10–1000), clamp the exponent before calling math.exp() to avoid OverflowError (which would produce an unhandled exception instead of a result):

import math

log_mtbf = exponent - math.log(denominator)   # work in log space
mtbf = math.exp(min(log_mtbf, 700))           # cap at e^700 ≈ 10^304 before overflow

# For results requiring display, prefer log-domain representation
# when the value may exceed float max (~1.8e308):
if log_mtbf > 700:
    result["MTBF_note"] = f"MTBF > e^700 seconds (effectively infinite)"

Always work in log space first (log_result = log_a - log_b + exponent) and only convert to linear (math.exp(log_result)) at the very end. This is standard practice for any formula of the form e^x / denom where x can be large.


Allowing R=0 in multi-segment RC networks

When a tool accepts a list of [R, C] segments representing an RC network, the last segment often has R=0 (a pure load capacitance with no series resistance). Validate using r < 0 (reject negative) rather than r <= 0 (reject zero):

for i, seg in enumerate(segments):
    r, c = float(seg[0]), float(seg[1])
    if r < 0:       # NOT r <= 0: zero resistance is valid for load-only nodes
        raise ValueError(f"Segment {i}: R must be non-negative, got {r}")
    if c <= 0:      # C=0 is never physically meaningful
        raise ValueError(f"Segment {i}: C must be positive, got {c}")

Dual-mode operation: fields.operation vs runtime argument

When a tool supports multiple operations, you have two choices for how the caller selects the mode:

Config-time dispatch (one tool per operation): Set the mode via "fields": {"operation": "..."} in the JSON config. Each JSON entry becomes a separate tool with its own name, description, and parameter list. The class reads self.operation from __init__. This is the cleanest approach when operations have different parameters.

Runtime dispatch (single tool, caller passes operation): Expose "operation" as an optional parameter in the JSON schema. Read it in run() with a fallback to self.operation:

def __init__(self, tool_config):
    super().__init__(tool_config)
    self.operation = tool_config.get("fields", {}).get("operation", "solve_power")

def run(self, arguments):
    # Runtime arg overrides config-time default
    op = arguments.get("operation") or self.operation
    if op == "solve_power":
        ...

Use runtime dispatch when a single JSON tool entry covers all modes and the description clearly explains all modes. Use config-time dispatch when different modes warrant different parameter descriptions (e.g., Circuit_dynamic_power vs Circuit_max_frequency).


Physical constants and preset lookup tables

Define physical constants and named preset tables at module level (not inside the class), so they are available to helper functions and are easy to read and update:

import math

# Physical constants
_MU0 = 4.0 * math.pi * 1e-7   # H/m — permeability of free space
_KB_EV = 8.617333e-5           # eV/K — Boltzmann constant

# Named preset table with physical values
_PACKAGE_THETA_JA: Dict[str, float] = {
    "sot-23":   200.0,   # °C/W
    "sot-223":   60.0,
    "to-220":    50.0,
    "tqfp-100":  35.0,
    "bga-256":   20.0,
}

In run(), resolve the parameter from: (1) explicit user value, (2) preset lookup, (3) default. Always give a clear error if the preset name is unrecognised:

def _resolve_theta(theta_ja, package):
    if theta_ja is not None:
        return float(theta_ja)
    if package is not None:
        key = package.lower().strip()
        if key in _PACKAGE_THETA_JA:
            return _PACKAGE_THETA_JA[key]
        raise ValueError(
            f"Unknown package '{package}'. Known: " + ", ".join(_PACKAGE_THETA_JA.keys())
        )
    raise ValueError("Provide theta_ja or a package name.")

Include the preset table in the metadata key of the return value so callers can discover valid preset names without reading the source:

return {
    "status": "success",
    "data": {...},
    "metadata": {
        "note": "Runs offline — no network request.",
        "package_presets": _PACKAGE_THETA_JA,
    },
}

Required-parameter extraction helper

When a tool has several required parameters, a small helper avoids repetitive None checks and gives a consistent error message:

def _req_float(arguments: Dict[str, Any], key: str) -> float:
    v = arguments.get(key)
    if v is None:
        raise ValueError(f"Required parameter '{key}' is missing.")
    return float(v)

def _req_int(arguments: Dict[str, Any], key: str) -> int:
    v = arguments.get(key)
    if v is None:
        raise ValueError(f"Required parameter '{key}' is missing.")
    return int(float(v))   # accept "1000" or 1000.0 as well as 1000

Call these from run() inside the try block — they raise ValueError on missing params, which is caught and returned as {"status": "error", "message": ...}:

def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
    try:
        current_A    = _req_float(arguments, "current_A")
        width_um     = _req_float(arguments, "width_um")
        num_stages   = _req_int(arguments, "num_stages")
        ...
    except ValueError as e:
        return {"status": "error", "message": str(e)}

Significant-figure rounding for wide-range outputs

round(x, N) works well when the magnitude of x is known, but fails silently when values can span many orders of magnitude — e.g., MTTF can range from milliseconds to decades, and round(0.00289, 1) returns 0.0 (the value is lost entirely):

>>> round(0.00289, 1)
0.0   # WRONG — looks like zero

For outputs that can span orders of magnitude, use a 4-significant-figure helper instead of round(x, N):

import math

def _sig4(v: float) -> float:
    """Round to 4 significant figures; handles any magnitude."""
    if v == 0:
        return 0.0
    mag = math.floor(math.log10(abs(v)))
    return round(v, -int(mag) + 3)
>>> _sig4(0.00289)
0.002887   # correct — 4 sig figs preserved
>>> _sig4(1234567)
1235000.0  # correct — 4 sig figs
>>> _sig4(26.088)
26.09      # correct

Use _sig4() for values like MTTF, inductance, safe current limits, and any other quantity that could legitimately be anywhere from sub-nano to mega. Use round(x, N) only when you know the output will always stay near a fixed scale.