Curriculum/Tools & Function Calling
Read vs Write Tools
Getters observe. Writers change the world. Mix them and retries become incidents.
Split the catalog into two families and put the label on the tool, not only in a prompt comment.
- Read tools —
get_ticket,search,list_orders. They should not change the world. Safe to retry. Often safe to run in parallel. - Write tools —
refund,send_email,merge_pr. They have side effects. Retry only if they are idempotent. Often need a human.
If a “read” tool secretly writes, your traces lie and your allowlists fail. sync_customer that “just updates the cache” is a write. Name it upsert_customer. The model will still mix them up sometimes. Your policy must not.
Many reads in one turn can be fine. Two refunds are an incident.
Reads retry. Writes cost.Why the split is a runtime fact
Retries are normal. The HTTP client retries. The model retries because the first observation fell off the context. The user double-clicks Send. A read that runs twice should return the same kind of blob (maybe fresher). A write that runs twice should not send two emails unless you meant to.
Parallel calls are normal too. Models emit several tool calls in one turn. Independent reads can run together. Two refunds of the same invoice cannot, unless they share an idempotency key. You will get a lesson on parallel dispatch. It is unusable if you cannot label read versus write.
Permissions split the same way. A research session may have every read and zero writes. A billing session may have get_invoice plus a capped refund. If you only have one bucket called “tools,” you will ship run_shell next to search because they shared a config list.
Side effects are the product risk
A wrong get_job wastes tokens. A wrong refund wastes money. Put risk on the tool itself: read, write, irreversible. Irreversible is a write with no realistic undo: wire transfers, public posts, deletes without a trash. Those wait for a human. Reads auto-approve. That policy belongs in the runtime table, not in “please be careful.”
Do not hide a write inside a getter. Engineers do this to save a round trip: get_or_create_ticket. Then every retry creates. Then the model calls it twice because the first observation truncated. Then you have three tickets. Split: get_ticket and create_ticket with an idempotency key. Extra round trips are cheaper than duplicate rows.
Logging must follow the label. Reads can log query and hit count. Writes must log before-and-after or a receipt id. If you cannot tell from the log whether the world changed, you labeled the tool wrong.
Retries, caps, and order
Reads: retry on timeout with backoff. Cap total read calls per turn so a confused model cannot search forever. Writes: do not retry on timeout unless you have a key or a status poll. A timeout is not “nothing happened.” The mail server may have accepted the message after you hung up.
Order matters when a write and a read share state. close_ticket then get_ticket must be sequential. Two get_ticket calls can be parallel. refund plus send_mail is two writes: queue them, key them, or ask a human. Do not “optimize” them into one god tool to make order go away.
The classroom world
Tickets and mail are enough to feel the split. get_ticket copies a row. close_ticket mutates state. send_mail appends to MAIL. The prints show: a read leaves the ticket open; a write closes it; two mails make two messages. That last fact is the incident. Reads can be sloppy. Writes cannot.
Try calling get_ticket twice. You should see the same title. Try calling send_mail twice with the same body. You should see count 2. That is why later lessons add keys. This lesson only makes you want them.
Run to execute this in your browser. Nothing is sent to a server.
What printed: the first get is ok and the ticket is still open. Close returns closed and the dict agrees. Two send_mail calls leave two rows in MAIL. If this were production without a key, Ada would have two emails. Label send_mail a write. Cap it. Key it. Do not call it a getter.
What goes wrong
A “search” tool that writes a telemetry row with PII. A “list_orders” that lazily creates the customer. A cache warmer named get_user that upserts. All of these make read-only sessions mutate production. Your allowlist said “reads only.” The function did not read the allowlist. The function did what it was written to do.
The other failure is refusing to label anything a write because “the model needs flexibility.” Flexibility is how you double-submit. Label every registry row. Default new tools to write until proven otherwise. Default-deny is cheaper than a surprise email.
How to test the label
For each tool, write a fixture world. Call the tool twice. If the world (row, mail list, refunded flag) changed twice, it is a write — and it is not idempotent yet. If the world did not change, it is a read, or it is a write that already had a key. Put that test in CI. Do not argue from the name.
How agents use this
The model may call many reads in one turn. Writes go through a tighter policy: caps, approvals, idempotency keys. Label every tool read or write in the registry, not only in the prompt. The loop reads that label when it decides parallel versus serial. The policy reads it when it decides auto-approve versus queue.
When you add a tool, fill the risk field before you write the description. If you cannot pick read or write, the tool is two tools. Split it. The agent loop is a client: it should not have to infer side effects from English. If a retry policy cannot be stated from the label alone, the label is wrong — split the function until a retry table is obvious.
Tip:Irreversible is a subclass of write. Use it for money, public posts, and deletes. Humans see that color in the approval UI.
Check your understanding