Curriculum/Tools & Function Calling
The Dispatcher
One table maps names to functions. Unknown names fail. Never run model text as code. Never import os because the model asked.
The dispatcher is boring on purpose:
- Parse JSON
- Look up the name
- Validate args
- Call the function
- Return a small observation
Unknown names fail. Never run model text as code.
One table maps namesIf the name is missing, return unknown_tool. Do not search globals(). Do not turn the name string into a program. Do not importlib.import_module because the name contains a dot. Boring is the security model.
The registry is that lookup table: a dict you typed, or a database row you deployed, mapping get_job to a function. It is an allowlist. Anything not in the table does not run. That one check is most of tool security. Permissions, sandboxes, and MCP filters are extra layers. They do not replace the table.
Explicit beats clever
Frameworks that “auto-bind any Python function” will bind helpers you forgot. A decorator scan of a package is a surprise. A dict you typed is a reviewable allowlist. Prefer the dict. If you use decorators, the CI test is: printed names equal the golden list in git. Surprise names fail the build.
Keep one registry per product surface (support bot vs billing bot). Copy-paste of the full catalog into every agent is how run_shell ships on Friday because someone cloned a demo. Surfaces share code for handlers. They do not share the enabled set. Enabled is policy. Handlers are implementations.
The dispatcher should not contain business logic beyond coerce-and-validate. get_job knows jobs. Dispatch knows names. If you pile Stripe rules into dispatch, you cannot test refund without a fake dispatcher. Keep it thin.
What dispatch must never do
It must never run model-written Python. It must never pass args to a shell. It must never follow a URL before a host allowlist (that check lives in the HTTP tool, called by dispatch). It must never catch all exceptions and return ok: true. Unexpected errors become internal_error with a correlation id, not a traceback, not a silent success.
It must never execute before validate. Order is the list above. Swap 3 and 4 and you have already refunded.
It must never log secrets. Redact after validate, before log, using the schema to know which fields are tokens. If you log raw args, the next incident report is your SIEM.
One table, many clients
The agent loop is a client. A cron is a client. A unit test is a client. A replay job is a client. All of them call dispatch(name, args). If the loop inlines handlers, replay has to pretend to be a model. If everything goes through dispatch, replay is a list of calls.
The live box has two names: get_job and finish. os.system is not in the dict, so it never runs. Print the bound names. That print is what you want in a debug endpoint (behind auth): the allowlist as data.
Run to execute this in your browser. Nothing is sent to a server.
What printed: get_job ok, finish ok, os.system is unknown_tool, bound names are finish and get_job. The dangerous string never became a process. The dict did that. Not a prompt.
What goes wrong
A default branch that tries getattr(handlers, name). A plugin folder loaded with import *. A “debug” tool left on in production. A registry that is a global mutated at runtime by the model’s register_tool idea — if you did not build a controlled plugin system with signatures, do not let the session grow the allowlist. Session policy may shrink the allowlist. It should not grow it from model output.
Another failure: two dispatchers. The prompt one and the “real” one. They drift. One table.
How to test the dispatcher
Unit-test with a fake model: a list of {name, args} dicts. Prove unknown names fail. Prove known names return JSON. Prove bad args do not call the world — use a handler that sets a flag, assert the flag is false on TypeError. Prove the printed name list matches the golden file.
One table per surface, one test without a model
Support and billing must not share the enabled dict even if they share handler code. Copy-paste of a demo registry is how run_shell arrives in a customer-facing bot. Review the printed name list in pull requests. Better: CI diffs that list against a golden file. Surprise names fail the build. Missing names fail too. The golden file is the allowlist you can audit at 3 a.m.
The dispatcher is the seam for replay. A trace is a list of {name, args}. Replaying means pushing that list through dispatch against a fixture world, not re-calling a vendor model. If handlers are inlined in the loop, replay has to fake thoughts. If handlers sit behind dispatch, replay is a for-loop. Write that for-loop in the same repo as the registry.
Thin dispatch also means: no Stripe rules inside the lookup function. Coerce and validate, then call. Business rules live in the handler, where tests can import them directly. If you pile policy into dispatch, every new tool becomes a tangle of ifs. A dict of callables plus a policy table scales. A 400-line switch does not.
Never grow the registry from model output. Sessions may shrink the enabled set. They must not add names the deploy did not ship. Plugins are a product with signatures, review, and fixtures — not a register_tool the intern invented at runtime.
How agents use this
The loop calls dispatch. It does not import domain SDKs. When you add a tool, you add a registry row, a schema, a description, a fixture, and a policy bit. If you only add a function, you added a helper, not a tool.
Disable by deletion from the table (or a flag the table reads). Do not disable by hoping the model will not emit the name. Emission is free. Execution is the line.
Watch out:Auto-import is how surprises execute. Type the dict.
Check your understanding