You wrote the JSON schema by hand, kept a name -> function dict, and called
fn(**json.loads(arguments)). Replace all three with a registry and an executor.
flowchart LR
subgraph Before
S1["hand-written schema"] --> D1["DISPATCH[name](**args)"]
D1 --> X1["KeyError / TypeError<br/>crash the request"]
end
subgraph After
S2["@registry.tool<br/>schema from the signature"] --> E2["executor.run_call"]
E2 --> R2["ToolResult: value or a safe error"]
end
Mapping
| Before |
After |
a tools=[{...}] list written by hand |
chat_tools(registry) / responses_tools(registry) / anthropic_tools(registry) |
DISPATCH[name](**json.loads(args)) |
await executor.run_call(ToolCall(name, json.loads(args), id)) |
try: ... except Exception: return str(e) |
result.error: a stable kind and a message safe for the model |
| your own retry loop |
retries=, timeout= on the tool |
| the agent loop you wrote |
run_tool_loop(model, registry, messages) with OpenAIModel / AnthropicModel |
Before
def exchange_rate(currency: str) -> float:
return {"USD": 0.92, "GBP": 1.17}[currency]
TOOLS = [ # written by hand, kept in step with the function by hand
{
"type": "function",
"function": {
"name": "exchange_rate",
"description": "Today's EUR rate for a currency.",
"parameters": {
"type": "object",
"properties": {"currency": {"type": "string"}},
"required": ["currency"],
},
},
}
]
DISPATCH = {"exchange_rate": exchange_rate}
def answer_tool_call_before(name: str, arguments: str) -> str:
return json.dumps(DISPATCH[name](**json.loads(arguments))) # KeyError, TypeError leak out
After
registry = Registry()
@registry.tool(timeout=5, retries=1)
def rate(currency: str) -> float:
"""Today's EUR rate for a currency."""
return {"USD": 0.92, "GBP": 1.17}[currency]
executor = Executor(registry)
async def answer_tool_call(call_id: str, name: str, arguments: str) -> dict[str, Any]:
"""One OpenAI tool call in, one `role="tool"` message out; failures become text."""
result = await executor.run_call(
ToolCall(name=name, arguments=json.loads(arguments), id=call_id)
)
content = (
json.dumps(result.value)
if result.ok
else json.dumps(result.error.model_dump() if result.error else {})
)
return {"role": "tool", "tool_call_id": call_id, "content": content}
print(chat_tools(registry)[0]["function"]["parameters"]["required"]) # generated
print(await answer_tool_call("c1", "rate", '{"currency": "USD"}'))
bad = await answer_tool_call("c2", "rate", '{"currency": 7}')
print(json.loads(bad["content"])["kind"]) # the model reads the error and retries
print([tool["name"] for tool in anthropic_tools(registry)]) # the same tool, Anthropic shape
blocks = tool_result_blocks(
[await executor.run_call(ToolCall(name="rate", arguments={"currency": "GBP"}, id="tu_1"))]
)
print(blocks[0]["type"], blocks[0]["tool_use_id"])
Or let the adapter run the whole loop:
replies: list[Any] = [
{"tool_calls": [ToolCall(name="rate", arguments={"currency": "USD"}, id="c3")]},
"1 USD is 0.92 EUR.",
]
async def fake_model(request: LLMRequest) -> Any: # in production: OpenAIModel(...)
return replies.pop(0)
question = (Message(role="user", content="USD in EUR?"),)
done = await run_tool_loop(CallableModel(fake_model), registry, question, executor=executor)
print(done.finish, done.final.text)
Output
0.92
['currency']
{'role': 'tool', 'tool_call_id': 'c1', 'content': '0.92'}
tool-args-invalid
['rate']
tool_result tu_1
stop 1 USD is 0.92 EUR.
Gained and lost
| Gained |
Lost |
| one schema source: the signature and docstring |
hand-tuned schema wording (use description=, Annotated and docstrings instead) |
| validation before the tool runs; errors the model can act on |
nothing: json.loads stays in your code |
| approval, budgets, caching, ledger and events on every call |
|
| the same tools on Gemini, MCP and 18 frameworks |
|
Checklist
- [ ] Every tool has a docstring and type hints (else
ToolDefinitionError at start-up).
- [ ] One
Executor, built at start-up.
- [ ] Each tool call becomes
run_call; its ToolResult becomes the tool message.
- [ ] Risky tools have
confirm= and the executor has an approval port.
- [ ] The old
TOOLS list and DISPATCH dict are deleted.
- [ ] No
spec.invoke outside tests.
Files
| File |
Does |
examples/guide_migrate_raw.py |
the old code, the new code, both renderings and a loop |
tests/docs/test_migration.py |
runs it offline and checks the output |