How Tool Schemas Are Built from Python Function Signatures in Needle 2
Needle 2 automatically converts annotated Python functions into JSON-compatible tool schemas that LLMs can invoke, using typing.get_type_hints, recursive type mapping, and optional Field constraints.
The cactus-compute/needle library eliminates manual schema writing by inspecting function signatures at runtime. This article explains the complete pipeline—from type hint resolution to final JSON assembly—based on the actual implementation in needle/agent/tools.py.
Overview of the Schema Building Pipeline
The core transformation happens in build_schema() at lines 17-46 of needle/agent/tools.py. The function takes any callable and returns a dictionary matching the OpenAI tool-calling format.
Here is the complete flow:
- Extract documentation from the docstring
- Resolve type hints with
typing.get_type_hints - Map Python types to JSON Schema primitives
- Detect optional parameters (union with
None) - Apply
Fieldconstraints for validation rules - Assemble properties and required fields
- Produce the final tool object
Step 1: Collecting Documentation with _parse_doc
Before types are processed, Needle extracts human-readable descriptions. The _parse_doc function at lines 99-112 parses the function's docstring to obtain:
- A short function description (first line)
- Per-argument descriptions from subsequent sections
from needle.agent.tools import _parse_doc
def example(a: int, b: str):
"""Do something.
Args:
a: First number.
b: Second value.
"""
pass
print(_parse_doc(example))
# {'description': 'Do something.', 'args': {'a': 'First number.', 'b': 'Second value.'}}
Step 2: Resolving Type Hints
At line 17, build_schema calls typing.get_type_hints(fn, include_extras=True) to get fully resolved annotations. This handles forward references, imported types, and generic specializations.
If resolution fails, Needle falls back to an empty dict (lines 19-20), allowing the function to proceed with minimal type information.
# From needle/agent/tools.py lines 17-20
try:
hints = typing.get_type_hints(fn, include_extras=True)
except Exception:
hints = {}
Step 3: Mapping Python Types to JSON Schema
The _json_type function (lines 57-86) performs recursive type-to-schema translation. It handles:
| Python Type | JSON Schema Output |
|---|---|
int, float, str, bool |
"integer", "number", "string", "boolean" |
enum.Enum |
{"enum": [...], "type": "<inferred>"} |
typing.Literal["a", "b"] |
{"enum": ["a", "b"], "type": "<inferred>"} |
list[T], typing.List[T] |
{"type": "array", "items": <schema(T)>} |
dict, typing.Dict |
{"type": "object"} |
Optional[T], T | None |
Processed as nullable (see Step 4) |
| Fallback | "string" |
# Simplified excerpt from _json_type implementation
_JSON_TYPES = {
int: "integer",
float: "number",
str: "string",
bool: "boolean",
}
def _json_type(annotation):
# Direct primitive mapping
if annotation in _JSON_TYPES:
return {"type": _JSON_TYPES[annotation]}
# Enum handling
if isinstance(annotation, enum.EnumMeta):
values = [item.value for item in annotation]
# Infer type from first value...
# Literal handling
if typing.get_origin(annotation) is typing.Literal:
values = typing.get_args(annotation)
# Build enum with inferred type...
# List/array handling
origin = typing.get_origin(annotation)
if origin in (list, List):
item_type = typing.get_args(annotation)[0]
return {"type": "array", "items": _json_type(item_type)}
# Default fallback
return {"type": "string"}
Step 4: Detecting Optional Parameters
Needle distinguishes required from optional arguments using _is_optional at lines 52-55. A parameter is optional if its annotation is a Union containing NoneType:
# From needle/agent/tools.py lines 52-55
def _is_optional(annotation):
origin = typing.get_origin(annotation)
if origin is Union:
return type(None) in typing.get_args(annotation)
return False
This detection affects whether a field appears in the required array (Step 6).
Step 5: Applying Field Constraints
For fine-grained control, Needle provides a Field class (lines 18-49) that carries validation metadata. When a parameter default is a Field instance, its apply method merges constraints into the schema.
Supported constraints include:
description– Override or augment docstring textge,le,gt,lt– Numeric bounds (minimum,maximum)enum– Restrict to specific valuespattern– Regular expression for strings
from needle import Field
def configure(
temp: int = Field(description="Target temperature", ge=0, le=100),
mode: str = Field(enum=["heat", "cool", "auto"])
):
"""Set thermostat configuration."""
pass
The resulting schema includes:
{
"temp": {
"type": "integer",
"description": "Target temperature",
"minimum": 0,
"maximum": 100
},
"mode": {
"type": "string",
"enum": ["heat", "cool", "auto"]
}
}
Step 6: Assembling Properties and Required Fields
The parameter loop at lines 23-39 iterates over inspect.signature(fn).parameters, skipping:
self,cls(bound method markers)*args,**kwargs(variable arguments)
For each remaining parameter, Needle:
- Generates base schema via
_json_type - Adds description from docstring if available
- Merges
Fieldconstraints viaField.apply - Determines if required: not required if it has a default value, a
Fieldwith default, or_is_optionalreturnsTrue
# Conceptual flow from lines 23-39
properties = {}
required = []
for name, param in inspect.signature(fn).parameters.items():
if name in ("self", "cls") or param.kind in (VAR_POSITIONAL, VAR_KEYWORD):
continue
# Build schema for this parameter
schema = _json_type(hints.get(name, str))
# Add description from docstring
if name in doc_args:
schema["description"] = doc_args[name]
# Apply Field constraints
if isinstance(param.default, Field):
param.default.apply(schema)
# Determine required status
has_default = (param.default is not inspect.Parameter.empty
and not isinstance(param.default, Field))
is_field_with_default = isinstance(param.default, Field) and param.default.default is not ... # noqa: E501
optional_type = _is_optional(hints.get(name))
if not (has_default or is_field_with_default or optional_type):
required.append(name)
properties[name] = schema
Step 7: Creating the Final Tool Object
Lines 40-46 construct the complete tool schema:
# From needle/agent/tools.py lines 40-46
result = {
"name": fn.__name__,
"description": description,
"parameters": {
"type": "object",
"properties": properties,
},
}
if required:
result["parameters"]["required"] = required
return result
The structure matches the OpenAI function-calling specification, enabling direct use with LLM APIs.
Using the @tool Decorator
For convenience, the @tool decorator (lines 68-70) attaches the generated schema as _needle_tool:
from needle import tool, Field
@tool
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
# Access the schema
print(add._needle_tool)
Output:
{
"name": "add",
"description": "Add two numbers.",
"parameters": {
"type": "object",
"properties": {
"a": {"type": "integer"},
"b": {"type": "integer"}
},
"required": ["a", "b"]
}
}
Pydantic Model Support
For complex structured data, pydantic_schema at lines 55-65 converts Pydantic models to tool schemas. It extracts properties and required from model_json_schema() (or the legacy schema() method):
import pydantic
from needle.agent.tools import pydantic_schema
class WeatherQuery(pydantic.BaseModel):
"""Weather lookup parameters."""
city: str
units: str = "metric" # Optional with default
print(pydantic_schema(WeatherQuery))
Output:
{
"name": "WeatherQuery",
"description": "Weather lookup parameters.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string"},
"units": {"type": "string"}
},
"required": ["city"]
}
}
Complete Working Example
from typing import Literal
from needle import tool, Field
import enum
class Priority(enum.Enum):
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
@tool
def create_task(
title: str,
priority: Priority = Priority.MEDIUM,
tags: list[str] = Field(default_factory=list, description="Categorization labels"),
assignee: str | None = None # Optional via union
) -> str:
"""Create a new project task."""
return f"Created: {title}"
print(create_task._needle_tool)
Generated schema:
{
"name": "create_task",
"description": "Create a new project task.",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"},
"priority": {
"enum": ["high", "medium", "low"],
"type": "string"
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Categorization labels"
},
"assignee": {"type": "string"}
},
"required": ["title"]
}
}
Summary
build_schemainneedle/agent/tools.pyis the central function that converts Python callables to LLM-compatible tool schemas.- Type resolution uses
typing.get_type_hints(..., include_extras=True)for accurate annotation extraction. _json_typerecursively maps Python types—including generics, enums, and literals—to JSON Schema fragments._is_optionalidentifies nullable unions to determine required vs. optional parameters.Fieldinstances attach validation constraints like numeric ranges and enum restrictions.- The
@tooldecorator automates schema attachment via the_needle_toolattribute. pydantic_schemaenables reusing Pydantic models as tool parameter definitions.
Frequently Asked Questions
What happens if my function has no type annotations?
Needle falls back to str for all parameters. The typing.get_type_hints call catches exceptions and returns an empty dict, causing _json_type to use its default "string" type for every argument.
Can I use standard libraries like datetime or Path in type hints?
Yes, but non-mapped types fall back to "string". For specialized JSON representations, use Field with a description clarifying the expected format, or wrap values in Pydantic models with custom validators.
How does Needle handle *args and **kwargs?
These are explicitly skipped in the parameter loop (lines 27-28). The generated schema only includes named, type-annotated parameters that an LLM can reasonably populate. Variable arguments are incompatible with structured tool calling.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →