Skip to content

windlass.tools.schema

schema

Automatic JSON Schema generation from Python functions.

The model chooses tools from their schema, so the schema is the prompt. Windlass derives it from what you already wrote — type hints and a docstring — so there is no second source of truth to drift out of date.

Supported annotations:

  • scalars — str, int, float, bool
  • containers — list[T], dict[str, T], tuple[T, ...], set[T]
  • Literal[...] becomes an enum, Enum subclasses become enums
  • T | None marks the parameter optional
  • Pydantic models and dataclasses expand into nested object schemas
  • Annotated[T, "description"] supplies a per-parameter description

Descriptions come from Annotated metadata, then from the docstring's Args: section, then from Pydantic Field descriptions.

Example

from typing import Literal def convert(amount: float, to: Literal["usd", "eur"] = "usd") -> float: ... '''Convert an amount. ... ... Args: ... amount: The amount to convert. ... to: Target currency. ... ''' ... return amount schema = function_schema(convert) schema["properties"]["to"]["enum"]['usd', 'eur'] schema["required"]['amount']

parse_docstring

parse_docstring(doc: str | None) -> tuple[str, dict[str, str]]

Split a Google-style docstring into a summary and per-argument descriptions.

Parameters:

Name Type Description Default
doc str | None

The raw docstring, or None.

required

Returns:

Type Description
str

A (summary, {arg_name: description}) pair. The summary is everything

dict[str, str]

before the first section header.

Example

summary, args = parse_docstring( ... "Add numbers.\n\nArgs:\n a: First.\n b: Second.\n" ... ) summary, args["a"] ('Add numbers.', 'First.')

Source code in src\windlass\tools\schema.py
def parse_docstring(doc: str | None) -> tuple[str, dict[str, str]]:
    r"""Split a Google-style docstring into a summary and per-argument descriptions.

    Args:
        doc: The raw docstring, or ``None``.

    Returns:
        A ``(summary, {arg_name: description})`` pair. The summary is everything
        before the first section header.

    Example:
        >>> summary, args = parse_docstring(
        ...     "Add numbers.\n\nArgs:\n    a: First.\n    b: Second.\n"
        ... )
        >>> summary, args["a"]
        ('Add numbers.', 'First.')
    """
    if not doc:
        return "", {}

    text = inspect.cleandoc(doc)
    match = _ARGS_SECTION_RE.search(text)
    summary = (text[: match.start()] if match else text).strip()
    summary = " ".join(summary.split("\n\n")[0].split())

    if not match:
        return summary, {}

    descriptions: dict[str, str] = {}
    current: str | None = None
    for line in text[match.end() :].splitlines():
        if not line.strip():
            continue
        if _SECTION_RE.match(line):
            break
        arg = _ARG_RE.match(line)
        if arg and not line.startswith(" " * 9):
            current = arg.group(1).lstrip("*")
            descriptions[current] = arg.group(2).strip()
        elif current:
            descriptions[current] = f"{descriptions[current]} {line.strip()}".strip()
    return summary, descriptions

python_type_to_schema

python_type_to_schema(annotation: Any) -> dict[str, Any]

Convert a Python type annotation into a JSON Schema fragment.

Parameters:

Name Type Description Default
annotation Any

The annotation to convert. inspect.Parameter.empty and bare Any produce a permissive {}.

required

Returns:

Type Description
dict[str, Any]

A JSON Schema fragment.

Example

python_type_to_schema(list[str]) {'type': 'array', 'items': {'type': 'string'}} python_type_to_schema(int | None)

Source code in src\windlass\tools\schema.py
def python_type_to_schema(annotation: Any) -> dict[str, Any]:
    """Convert a Python type annotation into a JSON Schema fragment.

    Args:
        annotation: The annotation to convert. ``inspect.Parameter.empty`` and
            bare ``Any`` produce a permissive ``{}``.

    Returns:
        A JSON Schema fragment.

    Example:
        >>> python_type_to_schema(list[str])
        {'type': 'array', 'items': {'type': 'string'}}
        >>> python_type_to_schema(int | None)
        {'type': 'integer'}
    """
    if annotation is inspect.Parameter.empty or annotation is Any:
        return {}

    # Annotated[T, "description", ...] -> unwrap, keeping any string metadata.
    if get_origin(annotation) is typing.Annotated:
        base, *metadata = get_args(annotation)
        schema: dict[str, Any] = python_type_to_schema(base)
        for item in metadata:
            if isinstance(item, str):
                schema.setdefault("description", item)
            elif isinstance(item, dict):
                schema.update(item)
        return schema

    if annotation in _SCALARS:
        return {"type": _SCALARS[annotation]}

    origin = get_origin(annotation)

    if origin is Literal:
        options = list(get_args(annotation))
        kinds = {type(o) for o in options}
        schema = {"enum": options}
        if len(kinds) == 1:
            only = kinds.pop()
            if only in _SCALARS:
                schema["type"] = _SCALARS[only]
        return schema

    if origin in (Union, types.UnionType):
        members = [a for a in get_args(annotation) if a is not type(None)]
        if len(members) == 1:
            return python_type_to_schema(members[0])
        variants = [python_type_to_schema(m) for m in members]
        return {"anyOf": [v for v in variants if v]}

    if origin in (list, set, frozenset):
        args = get_args(annotation)
        items = python_type_to_schema(args[0]) if args else {}
        schema = {"type": "array"}
        if items:
            schema["items"] = items
        if origin in (set, frozenset):
            schema["uniqueItems"] = True
        return schema

    if origin is tuple:
        args = get_args(annotation)
        if args and args[-1] is Ellipsis:
            return {"type": "array", "items": python_type_to_schema(args[0])}
        return {
            "type": "array",
            "prefixItems": [python_type_to_schema(a) for a in args],
            "minItems": len(args),
            "maxItems": len(args),
        }

    if origin is dict:
        args = get_args(annotation)
        schema = {"type": "object"}
        if len(args) == 2:
            values = python_type_to_schema(args[1])
            if values:
                schema["additionalProperties"] = values
        return schema

    if isinstance(annotation, type):
        if issubclass(annotation, enum.Enum):
            members = [member.value for member in annotation]
            schema = {"enum": members}
            kinds = {type(v) for v in members}
            if len(kinds) == 1 and kinds.copy().pop() in _SCALARS:
                schema["type"] = _SCALARS[kinds.pop()]
            return schema

        if _is_pydantic_model(annotation):
            # Duck-typed rather than importing pydantic: this module must stay
            # usable for tools whose annotations are plain classes.
            return _clean_model_schema(annotation.model_json_schema())  # type: ignore[attr-defined]

        if dataclasses.is_dataclass(annotation):
            return _dataclass_schema(annotation)

        if annotation in (list, dict, set, tuple):
            return {
                "type": {"list": "array", "set": "array", "tuple": "array"}.get(
                    annotation.__name__, "object"
                )
            }

    # Unknown annotation: stay permissive rather than emitting a wrong schema.
    return {}

function_schema

function_schema(
    fn: Callable[..., Any],
    *,
    name: str | None = None,
    description: str | None = None,
    skip: tuple[str, ...] = ("self", "cls")
) -> dict[str, Any]

Build a JSON Schema parameters object for a function.

Parameters:

Name Type Description Default
fn Callable[..., Any]

The function to inspect. Type hints are resolved against its module globals, so from __future__ import annotations works fine.

required
name str | None

Override for the tool name. Defaults to fn.__name__.

None
description str | None

Override for the description. Defaults to the docstring summary.

None
skip tuple[str, ...]

Parameter names to omit.

('self', 'cls')

Returns:

Type Description
dict[str, Any]

A JSON Schema object with type, properties, required and

dict[str, Any]

additionalProperties: false.

Raises:

Type Description
TypeError

If fn is not callable.

Note

*args and **kwargs are skipped — providers cannot express them, and a tool that needs them almost always wants an explicit list or dict parameter instead.

Example

def greet(name: str, excited: bool = False) -> str: ... '''Greet someone. ... ... Args: ... name: Who to greet. ... excited: Add an exclamation mark. ... ''' ... return name schema = function_schema(greet) schema["properties"]["name"]["description"] 'Who to greet.' schema["required"]['name']

Source code in src\windlass\tools\schema.py
def function_schema(
    fn: Callable[..., Any],
    *,
    name: str | None = None,
    description: str | None = None,
    skip: tuple[str, ...] = ("self", "cls"),
) -> dict[str, Any]:
    """Build a JSON Schema ``parameters`` object for a function.

    Args:
        fn: The function to inspect. Type hints are resolved against its module
            globals, so ``from __future__ import annotations`` works fine.
        name: Override for the tool name. Defaults to ``fn.__name__``.
        description: Override for the description. Defaults to the docstring
            summary.
        skip: Parameter names to omit.

    Returns:
        A JSON Schema object with ``type``, ``properties``, ``required`` and
        ``additionalProperties: false``.

    Raises:
        TypeError: If ``fn`` is not callable.

    Note:
        ``*args`` and ``**kwargs`` are skipped — providers cannot express them,
        and a tool that needs them almost always wants an explicit list or dict
        parameter instead.

    Example:
        >>> def greet(name: str, excited: bool = False) -> str:
        ...     '''Greet someone.
        ...
        ...     Args:
        ...         name: Who to greet.
        ...         excited: Add an exclamation mark.
        ...     '''
        ...     return name
        >>> schema = function_schema(greet)
        >>> schema["properties"]["name"]["description"]
        'Who to greet.'
        >>> schema["required"]
        ['name']
    """
    if not callable(fn):
        raise TypeError(f"Expected a callable, got {type(fn).__name__}.")

    signature = inspect.signature(fn)
    hints = _resolve_hints(fn)

    _, arg_docs = parse_docstring(inspect.getdoc(fn))

    properties: dict[str, Any] = {}
    required: list[str] = []

    for parameter in signature.parameters.values():
        if parameter.name in skip:
            continue
        if parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD):
            continue

        annotation = hints.get(parameter.name, parameter.annotation)
        schema = python_type_to_schema(annotation)

        doc = arg_docs.get(parameter.name)
        if doc:
            schema["description"] = doc

        if parameter.default is not inspect.Parameter.empty:
            if _is_jsonable(parameter.default):
                schema["default"] = parameter.default
        else:
            required.append(parameter.name)

        properties[parameter.name] = schema or {}

    out: dict[str, Any] = {
        "type": "object",
        "properties": properties,
        "additionalProperties": False,
    }
    if required:
        out["required"] = required
    return out