Earlier we built an approval gate: before a dangerous tool runs, ask a person y or n. That version
blocks. The agent sits and waits on input() while the human decides.
Blocking works at a terminal and breaks on the web. This post builds the durable version: the agent pauses, saves everything, returns, and resumes later, even in a different process.
Why blocking does not survive a web request
Picture the approval gate inside a web service. A request comes in, the agent runs, it reaches
delete_file, and it calls input() to wait for approval. Now the HTTP request is hanging, holding a
connection open, while a human somewhere decides. Wait long enough and the request times out. The whole
run is lost.
The real shape is different. The agent should get to the approval point, stop, and hand control back to the caller with "I need approval for this". The human decides whenever they get to it, minutes or hours later, and a fresh request resumes the run from exactly where it paused.
That needs the run's entire state saved somewhere durable between the pause and the resume. Sessions are exactly that somewhere.
Marking a tool as needing confirmation
First, tools declare whether they need approval, so the agent knows where to pause.
@tool(requires_confirmation=True)
def delete_file(path: str) -> str:
"""Delete a file. This cannot be undone."""
...
And the agent needs a way to represent "a tool call is waiting for a decision". That is a small data structure, saved in the session's state.
class PendingToolCall(BaseModel):
tool_call_id: str
name: str
arguments: dict
status: Literal["pending", "approved", "rejected"] = "pending"
The PendingToolCall is the whole reason this works across a gap in time. It captures exactly which
action is waiting, in a form that survives being written to a database and read back later.
Pause: save the state and return
When the agent reaches a tool that requires confirmation, it does not run it and does not block.
Instead it records a PendingToolCall in the session, saves the session, and returns a result that
says "paused, awaiting approval".
async def act(self, context, tool_calls):
for call in tool_calls:
if self.tool_map[call.name].requires_confirmation:
context.session.state["pending"] = PendingToolCall(
tool_call_id=call.id, name=call.name, arguments=call.arguments)
self.sessions.save(context.session) # durable: survives the process
raise Paused(call) # stop the run, hand control back
# normal tools run as usual
...
The save is the pivotal line. The pending action is now on disk, tied to this session. The process can
end, the server can restart, the human can go to lunch. The decision that still has to be made is safely
recorded, waiting.
Blocking holds the request open. Durable pause saves the pending action and returns.
Resume: load the state and continue
Later, a separate call arrives: session abc, the operator approved the pending action. The agent
loads the session, finds the PendingToolCall, runs it now that it is approved, and carries the loop on
from there.
async def resume(self, session_id: str, decision: str) -> AgentResult:
session = self.sessions.get(session_id)
pending = session.state["pending"]
if decision == "approved":
result = await self._run_tool(pending) # execute the saved action
context = ExecutionContext(events=session.events)
context.add_event(tool_result(pending, result))
return await self._loop(context) # continue where it paused
else:
# rejected: record the refusal, let the model choose another path
...
The run picks up exactly where it stopped, with the full history the session preserved. From the model's point of view nothing happened except that a tool took a while to return. From the outside, a human made a decision in between, in their own time, without a connection held open.
Resume loads the saved session, runs the approved action, and continues the loop from the pause point.
Why this is an internal feature, not a callback
We added earlier behaviour, like the blocking approval prompt, through callbacks that sit outside the
core loop. Pause and resume is different: it changes the control flow of run() itself, splitting one
run across two calls. That belongs inside the agent, not bolted on beside it, because a callback cannot
stop a run and restart it in another process.
The rule of thumb: a callback customises what happens at a point in the loop. A change to whether the loop continues now or later is core machinery.
| Blocking approval (chapter 5) | Durable pause/resume | |
|---|---|---|
| How it waits | Holds the process on input() | Saves state, returns |
| Survives a restart | No | Yes |
| Fits a web request | No, the request hangs | Yes, two separate requests |
| Where it lives | A callback beside the loop | Inside run() and resume() |
| Needs | Nothing extra | A session to save into |
What to take from this
- A blocking approval gate holds a request open while a human decides, which does not survive the web. The durable version pauses and resumes across two separate calls.
- The mechanism is a
PendingToolCallsaved into the session. It records exactly which action is waiting, in a form that survives the process ending. - Pause and resume is core machinery, not a callback, because it changes whether the loop continues now or later, which a callback cannot do.
The agent now remembers within a session and can pause across one. The last gap is memory that outlives the session entirely. The next post starts on long-term memory.