Resume an interrupted session#
A request can end without finishing. The connection drops and the retries run out, the user presses stop, the process is killed. The conversation is saved up to that point, and you want to carry on from there instead of typing “continue” and hoping the model works out what was missing.
resume() is run() without a new user message. The
session is restored, its last message says how it was interrupted, and the request
picks up from there.
try:
reply = await agent.run("Refactor the parser")
except ProviderTimeoutError:
reply = await agent.resume()
Ask before you resume#
Nothing records that a request did not finish: a process that was killed cannot write anything. What is left is the history, and its last message is the record. A finished request ends in an assistant answer that asked for nothing and stopped normally; every other ending is an interruption.
resume_point() reads the session and says which, without
changing it. It writes nothing and calls no model.
point = await agent.resume_point()
if point is not None:
show_button("Resume", tooltip=point.description)
It returns None when there is nothing to resume. resume() raises
AgentResumeError for those cases, with a reason,
so you can also just try it.
What resuming does#
The last message decides. kind on the ResumePoint says
which row applies.
History ends in |
How it got there |
What resuming does |
|---|---|---|
A user or internal message |
The model never answered: the connection failed, or the request was cancelled. |
|
Tool results, all there |
The tools finished and the next model call failed. Also a cancel during a tool, which already answered the call as cancelled. |
|
A step the provider paused |
The provider handed control back and the pause limit was reached. |
|
Tool calls with no result |
The process stopped while tools ran. |
|
A response that stopped as |
The stream was cut by a failure. |
|
A response that stopped as |
It hit the output limit. |
|
A normal answer |
The request finished. |
Nothing to resume. Raises |
A refusal |
The model declined. |
Raises |
A response that stopped as |
The context window was full. |
Raises |
Any other stop |
A value this library does not know. |
Raises |
An empty session raises "empty", and a history that ends in a tool result that
answers no call raises "malformed".
Tools are never run again on their own#
A crash can leave an assistant message that asked for two tool calls with a result for one. The other may have run, may have run halfway, or may never have started, and nothing says which. A tool that writes a file or sends a message may already have done so. Running it again could do it twice.
So the missing call is answered, not repeated. The model is given a failed result:
The process stopped while this tool was running, so it may or may not have completed, and whatever it changes may or may not have happened. Check the current state before running it again.
and decides what to do, which is usually to look at the current state and then repeat the call if it is needed. The result that did arrive is kept, so the model knows the first tool ran.
That is the same rule that applies when a new message arrives instead of a resume. Before the new input is appended, any tool call without a result is closed this way, because a provider rejects a history that has one. Restoring a session used to drop the whole interrupted batch, including the results that had arrived, so the model forgot that those tools had run. It now keeps them.
Download resume.py for a complete, offline
program that resumes after a failure and after a crash, and checks that no tool ran
twice.
async def main() -> None:
storage = SQLiteSessionExtension("resume.sqlite")
# A failure: the tool ran, then the connection dropped before the model saw the result.
agent = await Agent.create(FlakyModel(), config=AgentRunConfig("failure"), tools=[write_file], extensions=[storage])
try:
await agent.run("Write notes.txt")
except httpx.ConnectError:
pass
point = await agent.resume_point()
assert point is not None and point.kind.value == "call_model"
answer = await agent.resume()
assert answer.content == "Done: wrote notes.txt"
assert WRITTEN == ["notes.txt"] # resuming did not run the tool a second time
assert await agent.resume_point() is None # and there is nothing left to resume
# A crash: the process died after the model asked for two files and one was written.
# Nothing could be saved for the other, so its result is missing from the history.
calls = (ToolCall("a", "write_file", {"name": "a.txt"}), ToolCall("b", "write_file", {"name": "b.txt"}))
await storage.storage.create_session(session_id="crash")
for message in (
UserMessage(content="Write a.txt and b.txt"),
AssistantMessage(tool_calls=calls),
ToolMessage(tool_call_id="a", name="write_file", content="wrote a.txt", success=True),
):
await storage.storage.append("crash", "killed-request", message)
agent = await Agent.create(AnswerModel(), config=AgentRunConfig("crash"), tools=[write_file], extensions=[storage])
point = await agent.resume_point()
assert point is not None and point.kind.value == "recover_tools"
assert [call.id for call in point.unanswered_tool_calls] == ["b"]
answer = await agent.resume()
assert answer.content == "1 tool was interrupted" # the model was told, and decides what to do
assert WRITTEN == ["notes.txt"] # still nothing was run on its own
await storage.storage.close()
print("Resumed after a failure and a crash; no tool ran twice")
What to pass again#
Nothing about the interrupted request is stored except its messages. Pass the model,
tools, extensions and settings the interrupted request had, as you would for run.
You may pass a different model, for example when a provider keeps failing. Resuming does
not change whether that model accepts a history that another provider wrote.
Streaming, callbacks and the synchronous agent#
stream_resume() yields the same events as stream(), and AgentClient and
SyncAgent have resume, stream_resume and resume_point too. The events
that follow are an ordinary request’s: a resumed request ends in RUN_COMPLETED, and
an abnormal stop in the resumed step is reported through MODEL_STOPPED like any
other, so a response that is cut off again can be resumed again.
What is different:
There is no new user message, so the
on_messagehook is not called andcontext.input_messageisNone. Thebefore_runandafter_runhooks run as for any request. The JSONL extension starts the resumed turn at the last user message.A goal that was armed for the interrupted request is not armed again.
A session with nothing to resume raises as the request fails, and the error hooks run like for any failed request. Ask
resume_point()first to avoid that.
What this does not do#
It does not recover the text that was streaming when the request stopped. Partial output is saved only once the model finishes the step, so what a user saw on screen and the model had not completed is lost. After a cut-off response the model is told to start it again.
It does not retry for you. The runtime retries a failed request before any output arrives;
resume()is for when that is over, and it is the host that calls it.It does not stop two processes resuming the same session. The check that rejects a second request on a session works inside one process.
It does not know why the request stopped. The history says what it was doing, not whether the cause was a timeout, a stop button or a crash, so a host that wants to show the cause keeps it itself.
Next: Know why the model stopped for how a response’s ending is reported, and Keep and restore conversations for how history is saved and restored.
Related API: ResumePoint,
ResumeKind,
AgentResumeError.