Generic tool registry — the dispatcher pattern an LLM-driven agent uses to call typed tools. Each tool is a function StrictModel -> StrictModel; the registry maps a name to (input_schema, callable) and validates the dict-shaped input against the schema before dispatch. Layer-wise sits below agent / api / eval, above data / models (enforced by import-linter).
registry.Registry— instance class. Construct one per agent/test fixture; the module also exposes a globalregistrysingleton that the exampleecho_toolself-registers into at import time.register(name, input_schema)— decorator factory. Wraps a function and inserts the(input_schema, fn)pair undername. RaisesValueErroron duplicate registration.dispatch(name, raw_input)—name-lookup →input_schema.model_validate(raw_input)→ call. PydanticValidationErrorpropagates on bad input (wrong type or unknown keys viaStrictModel.extra="forbid").names()— sorted list of registered tool names; cheap introspection for the agent's tool-listing prompt.
registry.UnknownToolError—KeyErrorsubclass raised bydispatchwhen name isn't registered. Caught and rendered as a tool-call-error event by the agent loop.registry.registry— the module-globalRegistryinstance. Real agents use this so theecho_toolis reachable from any consumer; tests construct privateRegistry()instances to avoid cross-test contamination.registry.echo_tool+EchoToolInput+EchoToolOutput— example tool demonstrating the layer. Ships pre-registered under name"echo"to give a working dispatch path on a fresh clone.
- Inputs and outputs are
StrictModels. Avoid rawdicts — theextra="forbid"posture is the entire reason this pattern beats hand-rolledif/elifdispatch over JSON. - Tools are pure functions of their input. State the tool needs (DB connection, HTTP client) is injected via partial / closure at registration time, not via module globals.
- Add a tool = add a module. Real tools beyond the example get their own file under
src/tools/and self-register the same wayecho_tooldoes. Keepregistry.pyitself focused on the dispatcher.