Curriculum/Agent Architectures
Typed State Beats a Blob
A dict with allowed keys is a contract. A giant string named state is how illegal tools sneak in.
Agent state is not “whatever the last model said.” It is a typed object: a dict with allowed keys and allowed values. A giant string named state is how illegal tools sneak in. You cannot ask a paragraph whether phase is gather. You can ask a dict.
The assembler reads this object to pick legal tools. The parser refuses tools that do not match the phase. Stop may read phase == done. Checkpoints serialize this object. HITL stores frozen args beside it. None of that works if state is a 4k memory poem.
Memory stores (scratch, summary, notes) are logs and retrieval. Typed state is the contract of the run: where we are, what ids we hold, what is pending. Do not encode the contract only in the summary.
Keys that earn their keep
A typical object:
phase: gather | apply | doneticket_id: string or nullfacts: dict of checked claimspending_approval: frozen args or nullplan: list of step objects, or null
Phase is an enum. Refund is illegal in gather. The model does not set phase.
State is named boxesStart small. Every key should have a reader in code. Keys nobody reads are a blob again.
phase is an enum. Not “we are kind of ready.” If you need a new phase, add it to the enum and to the allowlist. Do not invent phases in a thought.
Assembler and parser both read it
Assembler: advertise only ALLOWED[phase]. Parser: if name not in that list, unknown_tool or illegal_in_phase. Defense in depth. If only the prompt says “do not refund yet,” the model will refund. If only the assembler hides refund, a buggy model call that still emits refund must die in the parser. If only the parser refuses, you still wasted tokens advertising it.
The live box uses can_run(state, name) as the contract. apply_obs updates ticket_id and phase when the world says the ticket is ready. The model does not set phase. The observation does, through your function. That is the same “world is truth” rule as the executor.
Updates are functions
Do not let the model patch state with free JSON merges. Apply a known observation through apply_obs. If get_ticket returns ready, phase becomes apply. If refund returns ok, phase becomes done. Unknown updates are errors.
Facts should be checked claims: {"window_ok": True} after policy code ran, not “seems refundable” from a thought.
Blobs hide illegal tools
A paragraph “We looked up T1 and it is probably fine to pay” does not change the allowlist. A typed phase: apply plus ticket_id: T1 does. Operators can print the object at 3 a.m. They cannot parse a poem.
Serialize this object in the checkpoint (later lesson). JSON round-trip. Do not pickle a custom class you forgot to version. Do not pickle a 4k-character “memory” string and call it state.
Typed state vs plan vs memory
| Object | Role |
|---|---|
| Typed state | Phase, ids, flags, pending approval |
| Plan | List of steps with status |
| Memory | Scratch, summary, notes |
A plan can live inside state as a key. Memory usually sits beside it (larger). Do not fold all three into one string for “simplicity.” You will spend the simplicity on bugs.
Tests without tokens
new_state() then can_run(..., "refund") is false. After a ready ticket, true. That test is cheaper than hoping the model “knows” not to refund in gather. State machines (next) add guards on transitions. Typed state is the object those guards read.
Who is allowed to write a key
Every key needs a writer you trust. phase and ticket_id update in apply_obs from tool output. pending_approval updates in the HITL pause from your freeze function. facts["window_ok"] updates from policy code, not from a thought that says “seems fine.” If the model emits a JSON patch, parse it as a proposal at most — then run it through the same functions. A free merge is a blob with extra steps.
Missing vs null vs wrong type are three bugs. Missing phase should fail closed at load (checkpoint corrupt). ticket_id: null is a legal gather. ticket_id: 12 as an int may break the assembler if you expected a string — normalize in apply_obs. Do not let two spellings (ticketId, ticket_id) exist. One schema.
Reads: the assembler reads phase for tools, the parser reads phase again, the UI reads phase for a badge, stop may read phase == done. If a field has no readers, delete it. If a reader needs a field that is only in the summary sentence, you failed this lesson — lift it into a key.
Version the object when you add keys (state_v: 1). Jobs will load old runs. Unknown keys on load: ignore or fail, pick one policy and test it. Dropping pending_approval on an old resume is how you execute an unfrozen refund.
The model may see a projection of state (phase, ticket id) in the assembled context. That is courtesy, like step 3/8. Enforcement stays in can_run and apply_obs. If you only print state in the prompt, you have a blob again.
Common mistakes
state = transcript[-1]["content"].- Model-written phase.
- Advertising all tools regardless of phase.
- Ticket id only in prose.
- Pickling blobs.
- One key
miscthat holds everything.
Run to execute this in your browser. Nothing is sent to a server.
Gather cannot refund. After a ready ticket, phase becomes apply and ticket is T1. That is a contract, not a hope. If ready were false, phase would stay gather and you would still hold the id. Add that case in your head: id without apply is allowed; refund is still illegal.
ALLOWED["done"] is finish only. After refund, even search is gone. That is how you stop the furnace from “just checking” after money moved.
How agents use this
Serialize this object in the checkpoint (later lesson). Do not pickle a 4k-character “memory” string and call it state.
The assembler budget filters tools from this object. HITL stores pending_approval on it. Jobs load it from JSON. If a teammate cannot print phase, you are back to anatomy: you cannot operate the loop.
Check your understanding