Skip to content

Write and register tools

A local tool is a plain Python function in tools/. There is no decorator, no registration call and no manifest — writing a tool is exactly as much work as writing a function.

Write it

tools/fitting.py
"""Curve fitting tools."""

from __future__ import annotations

import math


def power_law_fit(x: list, y: list) -> dict:
    """Fit y = C * x^a by ordinary least squares in log-log space.

    Returns {"exponent", "coefficient", "r_squared", "n"}. Raises ValueError
    if the inputs differ in length, contain fewer than two usable points, or
    contain non-positive values — the log transform is undefined there, and a
    silently dropped point is a silently biased fit.
    """
    if len(x) != len(y):
        raise ValueError(f"x and y must be the same length, got {len(x)} and {len(y)}")

    pairs = [(xi, yi) for xi, yi in zip(x, y, strict=False) if xi > 0 and yi > 0]
    if len(pairs) < 2:
        raise ValueError(f"need at least 2 positive points, got {len(pairs)}")

    log_x = [math.log(xi) for xi, _ in pairs]
    log_y = [math.log(yi) for _, yi in pairs]
    ...
    return {
        "exponent": slope,
        "coefficient": math.exp(intercept),
        "r_squared": r_squared,
        "n": len(pairs),
    }

Register it

You already did. Every .py file in the configured directory is imported, and every public top-level function becomes a tool:

science.toml
[tools.local]
path = "./tools"
science-adk tools
local.power_law_fit(x: list, y: list) -> dict
    Fit y = C * x^a by ordinary least squares in log-log space.
local.query_exoplanets(min_period: float = 0.1, ...) -> list
    Query the NASA Exoplanet Archive for confirmed planets.
Element Becomes
Function name Tool name (local.power_law_fit).
First docstring line The summary a coding agent reads.
Parameters + type hints JSON schema properties.
Parameters without a default The schema's required list.
Leading underscore (_helper) Not exported.

Type hints become the schema

def query_exoplanets(
    min_period: float = 0.1,
    max_period: float = 100000.0,
    max_rows: int = 5000,
) -> list:

produces

{
  "type": "object",
  "properties": {
    "min_period": {"type": "number", "default": 0.1},
    "max_period": {"type": "number", "default": 100000.0},
    "max_rows":   {"type": "integer", "default": 5000}
  },
  "required": []
}

Annotate every parameter. An unannotated parameter falls back to string, which is rarely what you meant and produces confusing errors at the call site.

Call it from an agent

fit = await self.call_tool("power_law_fit", x=axes, y=periods)

Arguments are keyword-only. Declare the tool on the node so the provenance gate can check that the call really happened:

{ "id": "analyze", "kind": "algorithm", "tools": ["power_law_fit"] }

Rules worth following

Raise instead of returning a sentinel

# Wrong: poisons the dataset silently.
def morgan_fingerprint(smiles: str) -> list:
    mol = parse(smiles)
    if mol is None:
        return [0] * 2048

# Right: the run fails, and the failure is visible.
def morgan_fingerprint(smiles: str) -> list:
    mol = parse(smiles)
    if mol is None:
        raise ValueError(f"could not parse SMILES: {smiles!r}")

An exception ends the run and fails execution_completed. A zero vector ends up in the training set and gets published.

Also:

  • Keep tools pure and deterministic where you can. A tool that reads global state is a tool whose result you cannot reproduce.
  • Validate inputs at the boundary. power_law_fit checks lengths and positivity before touching a logarithm.
  • Return plain, JSON-able data. Dicts, lists and numbers survive the trace; bespoke objects do not.
  • Write the docstring for a stranger. It is what a coding agent uses to decide whether this is the right tool, and it is all the documentation the tool will ever have.

Organising

One file per topic; the filename does not appear in the tool name, so keep names unique across the directory.

tools/
├── nasa.py       # query_exoplanets
├── fitting.py    # power_law_fit, linear_fit
└── descriptors.py

Private helpers just take an underscore:

def _log_pairs(x, y):     # not exported as a tool
    ...

Testing

Tools are ordinary functions, so test them ordinarily:

tests/test_fitting.py
import math
import pytest
from tools.fitting import power_law_fit


def test_recovers_a_known_exponent():
    x = [1, 2, 4, 8]
    y = [xi ** 1.5 for xi in x]
    fit = power_law_fit(x, y)
    assert math.isclose(fit["exponent"], 1.5, abs_tol=1e-9)
    assert fit["r_squared"] > 0.999


def test_rejects_non_positive_input():
    with pytest.raises(ValueError):
        power_law_fit([0, 1], [1, 2])

A tool with tests is the cheapest place in the whole system to catch a scientific error.

See also