Curriculum/Agent Architectures
Freeze the Approval Payload
Args at pause time are the contract. Resume must not pick up mutated dicts or extra keys from later thoughts.
Three classic HITL bugs:
- Freeze a reference to a dict the loop still mutates
- Let the model add keys after the human clicked Approve
- Resume the wrong run because ids were reused
Fix: freeze with a deep copy plus a hash at pause. Store run_id. On resume, compare hashes. If they differ, refuse.
Mutate the live dict all you want. The ticket must not notice.
Freeze is a copy plus a hashThe human may edit the form (new frozen snapshot, new hash). That is a new decision, not a silent mutation. Silent mutation is how 40 becomes 400 while the UI still shows 40.
This is the same idempotency idea as tool keys: the payload is the contract. Logs should show a short hash at pause and at resume. Operators should match them with their eyes.
A reference is not a freeze
In Python, frozen_args = args is a nickname for the same dict. The worker later does args["amount"] = 400 (or the model’s next thought merges keys into that dict) and the ticket lies. dict(args) is a shallow copy: nested dicts still alias. json.loads(json.dumps(args)) is a simple deep copy for JSON-safe values. Use that at pause. Then mutate the live dict all you want. The ticket should not notice.
The live box mutates live["amount"] after freeze. Frozen stays 40. Hash of live fails. Hash of ticket args passes.
Hash the canonical bytes
Sort keys. Dump JSON. Hash. Store a short hex. On resume, hash the args you are about to run the same way. Equality of hashes is the contract. Also keep the copied args so you can show the form again. Hash without copy cannot render the ticket. Copy without hash can drift if someone edits the store by hand — still compare.
If resume args pick up extra keys from a later thought, the hash changes. Refuse. Do not “merge extra metadata.” Extra keys are how you smuggle a second order id.
run_id is part of the contract
Resume must load this run. Reused ids (row 1 always) resume the wrong freeze. Generate run ids. Pass them in the operator link. If the id is missing, fail closed. Do not resume “the latest refund” globally.
Step number plus run_id plus hash is enough to debug “which click.”
Edit is a new freeze
The human changes amount to 35 in the form. That is not mutation of the old hash. You make a new snapshot, new hash, maybe require approve again. Audit: pause hash abc, edit hash def, resume def. Three lines. Silent in-place edit of the old ticket is how audit dies.
What not to freeze
Do not freeze the thought. Do not freeze the whole week of traces as the contract (you may link the trace). Freeze tool name plus args. Policy version is worth freezing too if the cap can change under you: “approved amount 40 under policy v3.” That is still data, not a poem.
After resume
Copy the frozen snapshot into the executor. Do not pass the live dict the worker still holds. After run, clear pending_approval. Checkpoint. If the tool fails, you have a separate error path — do not treat failure as a chance to mutate args and retry as if approved.
Canonical JSON, extra keys, and nested money
Hashing only works if both sides dump the same way: sorted keys, no extra whitespace surprises, the same types (40 vs 40.0 can be different JSON). Pick a canonical dump and use it at pause and at resume. Nested objects (customer: {id, email}) are why JSON round-trip beats dict(args). A shallow copy freezes the outer keys and still aliases the inner customer. Mutating live["customer"]["id"] then resumes the new id against an old hash if you hashed the outer dict before the inner change — or worse, the frozen args show the new id because they shared the inner dict. Deep copy first, then hash.
Extra keys after approve are not “metadata.” note from a later thought, notify: true, a second order_id — reject. If operators need a comment, store it beside the freeze (operator_note), not inside args the tool will receive. The executor should see only the blessed keys the schema allows.
List order in args: if items is a list, JSON dumps it in list order. Canonicalize lists if order does not matter, or treat order as part of the contract if it does. Be explicit.
Clock and random fields the model adds (requested_at) will make every pause unique and every resume fail if the worker adds a new timestamp. Strip unknown fields at parse time (tools schema) so they never reach freeze.
Log the short hash on the HITL row, the trace row, and the resume log line. Three places, same value, or refuse. That is how you debug “which click” without dumping amounts into Slack.
Common mistakes
frozen = args(alias).- Shallow copy of nested money objects.
- Merging extra keys on resume.
- Reused run ids.
- Editing in place without a new hash.
- Logging args and not hashes (hard to compare).
Run to execute this in your browser. Nothing is sent to a server.
The live dict changed to 400. The ticket stayed 40. Resume with live args fails the hash. Resume with ticket args passes. That is a freeze. JSON round-trip plus hash is the contract. A reference is not.
Twelve hex chars are a short fingerprint for the box. Use the full digest in production. Sort keys so amount then order_id hashes the same as the reverse insertion order.
How agents use this
This is the same idempotency idea as tool keys: the payload is the contract. Logs should show hash=abc at pause and at resume.
Checkpoints should store the freeze, not a pointer. Jobs should load JSON, not a live object from an old process. If resume cannot recompute the same hash, refuse. Fail closed is how money tools stay boring.
Check your understanding