Every agent project gets to the same point: a scattered collection of tool functions. Some have JSON schemas. Some have docstrings. Some have side effect notes. Some have default arguments. All of it lives in different places.
When the agent loop runs, it has to assemble this information from four different sources before it can call anything. agent-fn-registry puts it all in one place.
The Shape of the Fix
from agent_fn_registry import FnRegistry
registry = FnRegistry()
@registry.register(
side_effects="READ",
defaults={"max_results": 10},
)
def search_documents(query: str, max_results: int = 10) -> list[dict]:
"""Search the document store for relevant documents."""
return db.search(query, limit=max_results)
# Get the tool schema for the LLM
tool_schema = registry.get_schema("search_documents")
# {"name": "search_documents", "description": "Search...", "input_schema": {...}}
# Call with LLM-provided args (coercion + default filling included)
result = registry.call("search_documents", {"query": "agent reliability"})
Register once. Get schema. Call. The registry handles schema generation, default filling, and type coercion internally.
What It Does NOT Do
agent-fn-registry does not execute tools in parallel. Calls are synchronous. For parallel tool execution, you need to manage that at the agent loop level.
It does not validate that your tool implementations are correct. It validates arguments before calling, but if your function crashes with valid arguments, the exception propagates normally.
It does not manage tool authorization or access control. Every registered tool is callable by anyone with access to the registry. Authorization is a layer above.
Inside the Library
The registry is a dict from tool name to a ToolEntry:
@dataclass
class ToolEntry:
fn: Callable
schema: dict # JSON schema for input_schema
side_effects: str # READ / WRITE / IDEMPOTENT / DESTRUCTIVE
defaults: dict # arg name -> default value
description: str # from docstring or explicit
On @registry.register(), it calls tool-schema-from-fn to generate the JSON schema from the function signature and type hints. It reads the docstring for the description. It stores side effects and defaults.
On registry.call(name, args), it:
- Fills missing args from
defaults - Coerces arg types using
llm-tool-arg-coercelogic - Calls the function
- Returns the result
The three internal libraries (tool-schema-from-fn, tool-arg-defaults, tool-arg-coerce) are vendored into the package so the registry remains zero external deps.
The 26 tests cover: registration, schema generation, default filling, type coercion on call, unknown tool error, side effects metadata, listing all tools, and the full roundtrip of register-get_schema-call.
When to Use It
Use it when you have more than a handful of tools and you want one authoritative source for tool metadata. The registry pattern pays off when the same tool list is used in multiple places: the agent loop uses it for calls, the onboarding flow uses it to list capabilities, tests use it for schema validation.
The side effects metadata (READ, WRITE, IDEMPOTENT, DESTRUCTIVE) is valuable for agent decision logic. A planner that knows which tools have side effects can ask for confirmation before calling DESTRUCTIVE ones without needing that logic scattered through the tool implementations.
Skip it for simple agents with two or three tools. A registry adds indirection. If you can hold the tool list in your head, you do not need one.
Install
pip install git+https://github.com/MukundaKatta/agent-fn-registry
from agent_fn_registry import FnRegistry, SideEffects
registry = FnRegistry()
@registry.register(side_effects=SideEffects.READ)
def get_weather(city: str, units: str = "celsius") -> dict:
"""Get current weather for a city."""
return weather_api.get(city, units)
@registry.register(side_effects=SideEffects.WRITE)
def create_calendar_event(title: str, date: str, duration_minutes: int = 60) -> str:
"""Create a calendar event."""
return calendar_api.create(title=title, date=date, duration=duration_minutes)
# List all tools for the LLM
tools = registry.list_schemas()
# Pass to client.messages.create(tools=tools, ...)
# In your tool-call handler
def handle_tool_call(name: str, args: dict) -> str:
return registry.call(name, args)
Sibling Libraries
| Library | What it solves |
|---|---|
tool-schema-from-fn |
Generate JSON schema from function signature |
tool-side-effects-tag |
READ/WRITE/IDEMPOTENT/DESTRUCTIVE tags |
tool-arg-defaults |
Fill missing tool args from defaults |
tool-arg-coerce-py |
Coerce LLM tool args to expected types |
agentvet |
Validate tool call before execution |
agent-fn-registry is the composition of these lower-level libraries. If you only need schema generation, use tool-schema-from-fn directly. If you need the full package, use the registry.
What's Next
Async support is the main gap. Right now registry.call() is synchronous. An async_call() variant for async agent loops is straightforward to add.
Tool versioning would help for registries that evolve over time. Tagging each tool with a version and being able to load a frozen registry snapshot (for test isolation or audit replay) is a natural extension.
Access control hooks: a pre-call callback that receives (tool_name, args, context) and can raise or modify args before the call. This would let you add authorization, rate limiting, or audit logging without modifying the tool implementations.
Built as part of the agent-stack family: composable Python primitives for production LLM agents.
Top comments (0)