You maintain tool schemas for three LLM providers. Anthropic wants input_schema with JSON Schema. OpenAI wants parameters with a function object. Google wants function_declarations in a different nesting. When you add a new tool, you write the same thing three times. When you change a parameter, you find and update it in three places.
agent-tool-spec-pack takes Python function signatures and docstrings and generates tool schemas for all three providers at once.
The Shape of the Fix
from agent_tool_spec_pack import ToolSpecPack
pack = ToolSpecPack()
@pack.register
def search_documents(query: str, max_results: int = 5, include_metadata: bool = False) -> dict:
"""Search the document corpus for relevant passages.
Args:
query: The search query string.
max_results: Maximum number of results to return (1-20).
include_metadata: Whether to include document metadata in results.
Returns:
dict with 'results' list and 'total_count' int.
"""
return documents.search(query, max_results, include_metadata)
@pack.register
def create_ticket(title: str, priority: str, description: str = "") -> dict:
"""Create a support ticket.
Args:
title: Short title for the ticket.
priority: Priority level. One of: low, medium, high, critical.
description: Optional detailed description.
"""
return tickets.create(title, priority, description)
# Get schemas for any provider
anthropic_tools = pack.for_anthropic()
openai_tools = pack.for_openai()
google_tools = pack.for_google()
One function definition. Three provider formats.
What It Does NOT Do
agent-tool-spec-pack does not execute tools. It only generates schemas. Your tool registry or function dispatcher handles execution. The pack holds schemas and callables; you decide how to route a tool call to the right function.
It does not handle complex nested schemas. If your tool takes a Pydantic model as input, the type inference will not auto-expand the model fields into a flat JSON Schema. For complex types, you can pass an explicit schema_override to register().
It does not validate that the generated schema exactly matches each provider's requirements for all edge cases. Unusual type combinations (Union types, Optional with complex defaults, generics) may need manual schema_override. For standard Python types (str, int, float, bool, list, dict, Optional), generation is reliable.
Inside the Library
The schema generation uses inspect.signature() and typing.get_type_hints():
import inspect
import typing
def _build_json_schema(fn: Callable) -> dict:
sig = inspect.signature(fn)
hints = typing.get_type_hints(fn)
doc = inspect.getdoc(fn) or ""
properties = {}
required = []
for name, param in sig.parameters.items():
if name == "return":
continue
type_hint = hints.get(name, typing.Any)
prop = _type_to_schema(type_hint)
# Extract arg description from docstring
prop["description"] = _extract_arg_doc(doc, name)
properties[name] = prop
if param.default is inspect.Parameter.empty:
required.append(name)
return {
"type": "object",
"properties": properties,
"required": required,
}
The provider-specific formatters wrap the shared JSON Schema in the right envelope:
def for_anthropic(self) -> list[dict]:
return [
{
"name": name,
"description": _extract_fn_description(fn),
"input_schema": schema,
}
for name, (fn, schema) in self._tools.items()
]
def for_openai(self) -> list[dict]:
return [
{
"type": "function",
"function": {
"name": name,
"description": _extract_fn_description(fn),
"parameters": schema,
},
}
for name, (fn, schema) in self._tools.items()
]
def for_google(self) -> list[dict]:
return [{
"function_declarations": [
{
"name": name,
"description": _extract_fn_description(fn),
"parameters": schema,
}
for name, (fn, schema) in self._tools.items()
]
}]
The call(name, args) helper dispatches a tool call by name:
def call(self, name: str, args: dict) -> Any:
if name not in self._tools:
raise ToolNotFound(name)
fn, _ = self._tools[name]
return fn(**args)
When to Use It
Use it when your agent needs to support multiple LLM providers and you do not want to maintain separate schema definitions. The single-source-of-truth property is the main value: one Python function, one docstring, all formats.
Use it when you want type safety in tool definitions. Writing JSON Schema manually is error-prone. Deriving it from Python type hints means the schema stays in sync with the implementation.
Skip it for tools with complex input types. If your tool takes a typed dataclass or Pydantic model, the annotation-based schema generation will not expand the fields. You are better off writing the JSON Schema once and passing it as schema_override.
Skip it if you only target one provider. If you only call Anthropic, writing the Anthropic schema format directly is simpler than adding an abstraction layer.
Install
pip install git+https://github.com/MukundaKatta/agent-tool-spec-pack
# Or from PyPI
pip install agent-tool-spec-pack
from agent_tool_spec_pack import ToolSpecPack
pack = ToolSpecPack()
@pack.register
def get_weather(location: str, units: str = "celsius") -> dict:
"""Get current weather for a location.
Args:
location: City name or coordinates.
units: Temperature units. One of: celsius, fahrenheit.
"""
return weather_api.get(location, units)
@pack.register
def list_files(directory: str, pattern: str = "*") -> list:
"""List files in a directory.
Args:
directory: Absolute path to directory.
pattern: Glob pattern to filter files.
"""
return glob.glob(f"{directory}/{pattern}")
# Use with any provider
def run_with_anthropic(task: str) -> str:
response = anthropic_client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
tools=pack.for_anthropic(),
messages=[{"role": "user", "content": task}],
)
if response.stop_reason == "tool_use":
for block in response.content:
if block.type == "tool_use":
result = pack.call(block.name, block.input)
# ... continue loop
Sibling Libraries
| Library | What it solves |
|---|---|
tool-schema-from-fn |
Single-function annotation-to-schema (no registry) |
tool-arg-defaults |
Fill missing tool args from schema defaults |
tool-arg-coerce-py |
Coerce LLM tool args to expected Python types |
agent-fn-registry |
Registry combining schema, side effects, defaults |
tool-side-effects-tag |
Tag tools as READ/WRITE/IDEMPOTENT/DESTRUCTIVE |
The tool infrastructure stack: agent-tool-spec-pack for multi-provider schema generation, tool-arg-coerce-py for type coercion, tool-arg-defaults for default filling, tool-side-effects-tag for safety classification.
What's Next
Pydantic model support: when a parameter is annotated with a Pydantic model, auto-expand the model fields into a nested JSON Schema. This would handle the most common complex-input case without requiring schema_override.
Schema validation mode: pack.validate_schemas() that checks all registered schemas against each provider's documented constraints and returns a list of issues. Catches missing required arrays, forbidden format keys, and other provider-specific gotchas before runtime.
Diff output: pack.diff(other_pack) that shows which tools changed, which were added or removed, and whether any schema changes are breaking. Useful for code review when the tool library evolves.
Built as part of the agent-stack family: composable Python primitives for production LLM agents.
Top comments (0)