All posts
Fundamentals

Pause and resume, a durable human-in-the-loop

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.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.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 waitsHolds the process on input()Saves state, returns
Survives a restartNoYes
Fits a web requestNo, the request hangsYes, two separate requests
Where it livesA callback beside the loopInside run() and resume()
NeedsNothing extraA 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 PendingToolCall saved 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.