JJoeven

Curriculum/Agent Architectures

Long-Running Agents

A job with a store and a wakeup, not a request that holds a socket for an hour.

intermediate21 min16 / 24

If a loop may wait on a human, a batch job, or tomorrow’s email, it is a job:

  • run_id
  • serialized state
  • wake_when (event, cron, or approval)
  • a worker that loads, steps, and saves
A job, not a held socket
LoadStepSaveWake

The worker loads, takes a slice, saves, and sleeps. The store is the truth.

A job, not a held socket

Do not hold an HTTP request open for an hour. Do not keep a chat socket as your only store. Cloud functions time out. Laptops sleep. The browser tab closes. HITL “waiting_human” is overnight by nature. An agent that emails you in three days is this pattern, not a longer prompt.

The loop you already wrote becomes step_job(state) -> new_state. The platform retries the worker, not the user’s browser tab. Anatomy still holds: each wakeup, you assemble (maybe), or you apply an event, then save. You do not restart the furnace from step 1 unless the checkpoint says so.

Requests die, jobs resume

A request is a live connection. It has a timeout. It has a client that can hang up. It is the wrong container for “wait until the invoice email arrives.” A job record is a row (or document) that outlives the process. The worker is a short function: load, maybe step, save, exit. A scheduler or queue calls it again when wake_when matches.

If you only store the loop in RAM, a deploy kills the agent. If you only store the transcript in the model’s context window, you cannot wait at all. If you store in the user’s clipboard, you are joking — and yet people try “copy this JSON and paste tomorrow.” Use a store.

The four fields

run_id — unique. Resume, traces, HITL links, freeze hashes all key off it.

serialized state — typed state plus memory pointers plus pending freeze. JSON. Version it if you can (v1). Pickle is how you cannot load after a class rename.

wake_when — why this job is asleep: mail:invoice, cron:tomorrow, approval:tix_9, tool:timeout-retry. Null means runnable now or done.

workerstep_job. Idempotent enough that a double wakeup does not double-write unless you intend it. Tools track owns idempotency keys. The worker should not call refund twice because the queue delivered twice — checkpoint and keys.

step_job is the loop in slices

Each call does a little:

  • phase start → park at wait_email, set wake_when
  • phase wait_email without email → return unchanged (still waiting)
  • phase wait_email with email on state → done, answer, clear wake_when

The email arrives on a different event: some inbound handler writes state["email"] and saves, then the worker runs. The socket that received the mail is not the agent loop. The store is the truth.

HITL pause is a wakeup: approval event sets a decision, worker resumes freeze check, maybe executes. Tool timeouts are a wakeup: retry once later, not while holding the request.

Do not step forever in one worker

A worker that internally while True until done reintroduces wall-time death. Slice: one phase, or a small step cap, then save. The next wakeup continues. Stop budgets still apply across slices: store steps_used on state. An overnight job with no step cap is still a furnace; it just bills slowly.

What the user sees

The user sees “working” or “waiting on email / waiting on a person.” They do not see a spinner tied to one HTTP call for an hour. Operators see run_id and wake_when. If wake_when is stuck, the job is stuck — not the model.

What this is not

This is not a production course in queues and brokers. Dicts and a STORE in the live box are the shape. When you go to a real queue, keep the same names: run_id, state, wake_when, step. If the cloud diagram cannot map onto that, you bought a maze. Prod-as-a-track comes later. Here you need the loop to survive sleep.

Multi-agent does not replace jobs. Five specialists in one request still die when the request dies.

Events, crons, and who is allowed to write state

Wakeups are events you name: mail received, approval clicked, cron tick, tool retry due. Each event handler should write a small, typed update (email id, decision, clock) then call the worker — or enqueue the run_id. Do not let the mail handler execute refund itself. It does not own the loop. It owns “this mail arrived.” The worker owns phase transitions.

Cron is a wakeup that says “it is tomorrow,” not a second brain. If nothing is due, step_job returns unchanged. That is cheap. A cron that starts a new agent every tick without run_id is a furnace factory.

Idempotency of the worker: two deliveries of the same mail should not file twice unless you want that. Store processed event ids on state, or use tool keys. step_job on wait_email when email is already filed should see phase done and return. The live box does not show that guard; add if phase == done: return state in your head. Missing it is how double wakeup double-files.

Serialization must round-trip every field the next step reads: phase, wake_when, email, pending freeze, steps_used, memory pointers. If JSON drops None vs missing, normalize on load. If you store only the transcript, you cannot wake. If you store only phase and forget wake_when, the scheduler cannot find you.

The user-facing “waiting on email” is a projection of phase. Operators get run_id. Support should not need to keep a tab open. That is the whole point of a job.

Common mistakes

  • Open HTTP for an hour.
  • RAM-only loop.
  • Worker while-True until dawn.
  • No run_id.
  • Pickle.
  • Double wakeup double refund.
  • Prompt: “wait three days then continue” with no store.
Live PythonOpen full playgroundpython
Output
Run to execute this in your browser. Nothing is sent to a server.

Start parks the job. Wakeup with an email finishes it. The store is the truth, not the socket. After start, phase is wait_email and wake is mail:invoice. After the mail is written onto state and stepped, phase is done and the answer cites m9. A step in the middle without email would have returned the same wait.

JSON dump/load is the serialization. If you added a nested freeze dict, it would survive the round trip. A live Python object in a global would not survive a process restart — this STORE is a stand-in for a database.

How agents use this

Queue + DB + worker. HITL pause is a wakeup. Tool timeouts are a wakeup. “Agent that emails you in three days” is this pattern, not a longer prompt.

Checkpoints (next) are the snapshots inside the job. Jobs without checkpoints restart blindly. Checkpoints without jobs still die when the process dies. You want both.

Check your understanding

How should a loop that waits overnight be stored?