Curriculum/Tools & Function Calling
Idempotency
Agents retry. Networks retry. The model retries because it did not read the first observation. Same key, same result.
Agents retry. Your HTTP client retries. The model retries because the first observation fell off the context. Timeouts happen after the world already changed. If a write is not idempotent, every retry is a possible double submit.
refund(invoice_id) should not double-pay if called twice. Use an idempotency key or “already refunded → return the same receipt.” The second call is a read of the first write, not a second write. Clients should see ok: true and the same receipt, plus idempotent: true so logs can tell you it was a replay.
Reads are usually naturally idempotent. Writes must be designed that way. Creates are hard. Prefer create_ticket(idempotency_key, ...) over “open a ticket” that always inserts. If the model invents a new key every turn, you still double-create — so also key off a natural id (invoice_id, ticket_key) when you have one.
Agents retry. Networks retry. The money must move once.
Same key, same receiptKeys you can actually use
A client-generated key is a string the loop passes on every attempt of the same logical action. Store key → result for a bounded time (24 hours is a common API default). Same key, same args, same result. Same key, different args: conflict error. Do not silently take the new amount.
Natural keys: one refund per invoice, one close per ticket. They survive the model forgetting the client key. Use both when you can: natural key for the business rule, client key for in-flight retries of creates that have no natural id yet.
Do not use wall-clock or random as the key. The next call will not match. Do not hash the prompt. Hashing args can work if you freeze JSON field order and include the user id. Prefer an explicit field.
Timeouts and unknown outcome
A timeout is not “nothing happened.” The refund may have posted. The safe next step is poll status or retry with the same key. A new key is a new refund. The timeouts lesson repeats this. Idempotency is why that advice works.
If you cannot poll, the key store is the poll: retry the write with the same key and return the stored receipt. If the first attempt is still in flight, return pending with the same key, not a second Stripe call.
Scope the key
Keys are per tenant, per tool, per user. Ada’s key k1 must not replay Bob’s refund. Include tenant in the store index. Include tool name so refund:k1 and email:k1 do not collide unless you meant that.
TTL the store. Infinite keys are a data dump. After TTL, a replay may double — document that and pick a TTL longer than your longest client retry window.
Classroom refund
The invoice dict holds refunded and receipt. First call moves money (here: sets a hash receipt). Second call returns the same receipt and idempotent: true. Wrong amount errors even after refund so you cannot “retry” a different value through the same invoice. Missing invoice is not_found.
Run to execute this in your browser. Nothing is sent to a server.
What printed: first ok with idempotent false. Retry ok with idempotent true and the same receipt. Bad amount invalid_args. Missing not_found. The dict still holds one receipt. The money moved once. That is the product.
What goes wrong
Keying only in the HTTP client and not in the tool (the model still double-calls with two HTTP requests). New UUID per turn in the assembler. Treating idempotent: true as a failure. Clearing the flag on “retry” because a human clicked. Hashing args without tenant. All of these double-submit.
How to test keys
Call write twice with the same natural id: same receipt, world changed once (flag or list length). Call with different amounts: conflict or invalid_args, still one change. Call two invoices: two receipts. Restart the fake store (new dict) only in tests that intend a cold start.
Keys survive crashes, not just polite retries
The interesting failure is not “model called twice in one turn.” It is: the worker died after Stripe accepted the refund and before you wrote the receipt. The next slice retries. Without a key store that Stripe also honors, you charge twice. Persist the key before the remote call when you can, or use the provider’s idempotency header with the same string you stored. Poll if the first attempt is still in flight. Return pending on the same key rather than starting a sibling charge.
Natural keys and client keys do different jobs. Natural: one refund per invoice, even if the model mints a new UUID every turn. Client: one create among many similar creates that have no invoice yet. Use both when you have both. Same key plus different args is a conflict, not a second amount. Do not “update” a refund because the model changed its mind.
Scope the store: tenant, tool name, key. Ada’s k1 is not Bob’s. refund:k1 is not email:k1. TTL longer than the longest retry window, shorter than forever. After TTL, a replay may double — document it. Infinite key stores are a PII dump with extra steps.
The loop must reuse the key from the first call object. Assemblers that mint uuid4() on every turn make the field decorative. Put the key on the schema as required for writes that can double. The runtime enforces equality with the store. The prompt cannot.
How agents use this
Pass a client-generated key on creates. Store key → result for 24 hours. Also key off a natural id when you have one. The loop should reuse the key from the first call object, not mint another. Put the key on the tool schema as a required field for writes that can double.
The runtime enforces this, not the prompt (“please do not refund twice”). Prompts are not keys.
Note:Reads do not need keys. Writes without keys need a human or a natural unique constraint.
Check your understanding