History and Checkpointing¶
An AgentSession holds a conversation in memory and writes nothing to disk on its own. This page
is the map of that history: where it lives, when it changes, what a turn, an error or an abort
leaves behind, how to checkpoint it with to_json() / from_json(), what a save does with media
and whether you need a store, and what a restore under another model or another provider does
(a provider-native history is converted, and the conversion is reported). Sessions documents the session object
itself; this page is about keeping the conversation between processes.
Where history lives, and when it changes¶
- In memory, on the session.
session.messagesis a plain list of provider-native dicts, andsession.metadatais the accounting block (tokens, cost, run count, compaction counters, run boundaries, parked late injections, served models). Both belong to theAgentSessionobject and to nothing else. - Updated once per completed turn.
run()appends the user message before the request and, when the turn completes, replaces the working list with the provider's echoed transcript plus the final answer.stream()does the same when itscompleteevent arrives. Nothing is written mid-turn. - Written nowhere unless you write it. The toolkit never persists a session by itself. The compaction archive, when enabled, is the only file a session produces, and it is not a checkpoint.
What a turn, an error and an abort leave behind¶
| Event | What session.messages holds afterwards |
What you get |
|---|---|---|
| A completed turn | every earlier turn plus this one (user message, tool rounds, final answer) | the AgentResult, a new run_boundaries record, updated counters |
| A turn that raised (provider error, hook abort, timeout) | every completed turn; the failing turn's question is removed and its partial transcript (the tool calls so far) is not stored | the exception, with undelivered_injections on it and parked in metadata["pending_injections"]; the question on session.interrupted_turn |
| A turn that returned an error-shaped result with nothing to store (retries exhausted, a non-retryable API error) | as above | the AgentResult with error set; its spend banked in metadata; the question on session.interrupted_turn |
| A stream closed early | as above; the spend is banked on metadata and last_turn_results |
the abandoned record on agent.last_abandoned_stream; the question on session.interrupted_turn |
The resume point after any failure is therefore the last completed turn. If the partial
transcript of a failed turn matters to you, journal tool results from the tool_call_result hook
(Hooks and Steering).
The interrupted turn. A failed turn's question does not stay in history as a dangling user
message that the next turn would send unanswered. It is recorded on metadata["interrupted_turn"]
(session.interrupted_turn returns a copy): the message as sent, files_attached, the error
and error_type (the exception class, "AgentResultError" for an error-shaped result,
"StreamAbandoned" for a stream closed early), failed_at (UTC), the run number the turn would
have had, and any undelivered_injections. The record survives the envelope and is cleared when the
next run() / stream() starts, so a host that checkpoints after the failure can decide, in
another process, whether to resend the message. The files themselves are not kept (only their
count); resend them with the message. DPT_SESSION_INTERRUPTED_TURN=off restores the dangling
message (Configuration).
One turn at a time. A second run() or stream() on a session whose turn is still running
raises RuntimeError("a turn is already running on this session ..."). Before this guard the two
turns appended to the same history and interleaved silently. session.turn_in_flight says whether
a turn is running; queue turns in the host, or use the agent factory's on_busy="queue".
Attachments are part of history. A file passed with files= is stored with the turn on every
path, in the provider's own user-message shape: from the provider's echoed transcript on the tool
loop, and rebuilt from the parts the agent sent on the paths that return no transcript (the
no-tools path of run(), and a plain-text stream()). Before this release those two paths kept the
text only, so a later turn could not see the attachment. A structured output is stored as JSON
text, not a Python repr.
Checkpointing with to_json() and from_json()¶
to_json() is the checkpoint primitive. It writes the envelope to_dict() produces as JSON, and
with path= it writes the file atomically (a sibling temporary file and a rename), so a
checkpoint interrupted mid-write never replaces the previous good envelope with a truncated one.
from doc_processing_toolkit import Agent, AgentSession
agent = Agent(model="claude-haiku-4-5", instructions="You review contracts.")
session = agent.session()
result = await session.run("Summarise clause 4.", files=["contract.pdf"])
session.to_json(path="checkpoints/contract-review.json") # after every completed turn
# ... later, in another process ...
session = AgentSession.from_json("checkpoints/contract-review.json", agent=agent)
result = await session.run("And clause 5?")
from_json() accepts JSON text, UTF-8 bytes, or a path (a str whose first non-blank character
is { or [ is read as text; anything else is a path; a pathlib.Path is always a path).
Everything after parsing is from_dict(): the same versioning, fingerprint and compatibility rules.
Cadence. Checkpoint after every completed turn. A turn is the unit of work the provider bills and the unit the session stores, so the file always holds a resumable conversation and never a half-turn. Checkpointing more often buys nothing; less often loses turns.
The envelope is a detached copy. to_dict() normalises messages to JSON-clean values and
deep-copies metadata, so a host that edits its saved dict never edits the live session through it.
What the envelope records (version 3). Besides the conversation and the accounting, the envelope
now says who wrote it: provider and model of the saving agent, toolkit_version, saved_at
(UTC), sdk (the provider SDK's distribution name and version), media_policy (the policy the save
applied; see Media in a checkpoint), media (the media table) and
media_report (what a save or a restore could not keep). Which model actually answered each
turn is in metadata["served_models"], one record per run and served model, taken from the run's
per-request cost records. Every new key is top-level and optional: a version-2 reader replays an
inline version-3 envelope exactly as it would a version-2 one, and a version-3 reader restores
version 1 and 2 envelopes as before (envelope versioning).
OpenAI reasoning replays on its own. A request on an OpenAI reasoning row asks for
include=["reasoning.encrypted_content"], and the session keeps that payload on the reasoning items
it stores, so a restored OpenAI conversation replays its reasoning without OpenAI's 30-day response
store and under zero-data retention. Before this, the stored items carried only their rs_ ids and
replay depended on the store. A reasoning item exists only where the model actually reasoned: the
GPT-5.6 and GPT-6 rows return none on a turn that needed no reasoning (a trivial tool call at
effort="low"), and only the tool-calling path stores provider items at all (the no-tools path
stores the question and the answer). DPT_OPENAI_RETAIN_ENCRYPTED_REASONING=off restores the
previous request and storage
(Configuration).
Measure before you write. session.persistence_report returns the envelope's size in bytes, its
message count, and one record per media part (kind "base64" for bytes carried inline,
"google_file_uri", "provider_file" or "url" for references, with the decoded size_bytes), so
a host can decide where an envelope should live before writing it.
Media in a checkpoint¶
Media enters history in the shape each provider took it: base64 inside an Anthropic source, a data
URL inside an OpenAI or Fireworks part, inline_data inside a Gemini part, and, for the media Gemini
took by upload (audio, video, anything above its inline limit), a Files API file_uri that expires
48 hours after the upload. The media policy decides what a save does with those parts. It is set
on the agent (Agent(media_policy=..., media_store=..., media_max_inline_bytes=...), the same keys
in YAML and on the builder) and can be overridden per save
(to_json(media=..., media_store=..., media_max_inline_bytes=...), and the same on to_dict).
media_policy |
What the save writes | When |
|---|---|---|
"auto" (default) |
externalize when a store is configured, inline otherwise |
the setting to leave in place |
"inline" |
every media part exactly as the session stores it | no store; the envelope is self-contained and can be large |
"externalize" |
every inline payload goes to the MediaStore under its SHA-256 and the part keeps a $media:<ref> marker in its place; the envelope's media table records ref, sha256, mime_type and size |
a store is configured; envelopes stay small and blobs are de-duplicated |
"strip" |
every inline payload becomes a text placeholder ([image/png, 136 B, removed on save]) and a media_report entry |
the media is not needed again (it was described, extracted or summarised on the turn it arrived) |
Three things hold under every policy. A save works on a copy: the live session's messages are
never rewritten, so a session saved with strip keeps its media in memory and can be saved again
with inline. A reference part (a Google file_uri, a YouTube URL, a provider file id) is never
externalised or stripped by a save; it is small, and the bytes behind a Google reference are handled
by the media table below. And externalize without a store is refused with ValueError at save
time rather than silently falling back.
Google uploads and the 48-hour expiry. A file Gemini took through the Files API is stored in
history as its file_uri, and the toolkit treats that URI as a cache. Every upload a run makes is
reported on AgentResult.media_uploads (uri, name, mime_type, size, sha256,
uploaded_at, expiration_time) and recorded in the session's media table beside the URI with
its bytes, so a restore after the expiry can re-upload them: in the store, as a reference, when a
media_store is configured; inline in the table when the part is within media_max_inline_bytes
(8 MiB by default; None lifts the cap); and otherwise not at all, with one WARNING and an
over_inline_threshold entry in media_report saying the part will be lost after the expiry. Inline
parts on the other providers are never affected by the cap. Before a turn on a restored session sends
a stored URI, the session checks it in three steps, cheapest first: the recorded expiration_time
decides without a network call; a URI with no recorded expiry (an envelope written before this
release) is checked once with files.get; and if the request still fails naming a file, every URI is
re-checked and the request is retried once (the retry is billed like any request and counted in
total_retry_count; on stream() only the two pre-turn checks apply, and a request-time failure
is reported as an interrupted turn). A dead URI
whose bytes are known is re-uploaded and the message and the table are rewritten
(expired_reuploaded in media_report, with the new URI); one whose bytes are unknown becomes a
placeholder (expired_no_bytes) and the turn goes ahead without it. A Files API URI is bound to the
API key that uploaded it, so the bytes are also what make an envelope movable between keys and
accounts.
media_report. What a save or a restore could not keep, as a list of records that rides the
envelope and grows across saves and restores. reason is one of over_inline_threshold, stripped,
missing_blob (a store reference the restore could not read, or no store was given),
expired_reuploaded or expired_no_bytes, with the part's mime_type and size and, where it
applies, its uri, message_index and expires_at. A missing blob never fails a restore: the part
becomes a placeholder ([image/png, 136 B, not available on restore]) and the record says so.
Restored history is validated against the target row. A restore replays the input_types check
the current turn's parts get, under the agent's on_unsupported_part policy: a stored part the
target model cannot accept (a PDF restored onto a row without document input, an audio upload onto a
text-only row) is replaced by a placeholder and recorded on session.dropped_parts (kind, index,
reason, provider, model), or refused with UnsupportedPartError under "raise". Before this
the part reached the provider and failed with a 400 on the first request.
preserve_media: False strips at storage time. The media policy acts when a session is saved.
session_history_config={"preserve_media": False} acts when a turn is stored: every media part of
the turn, inline bytes and Google references alike, becomes a text placeholder, so no later turn
replays it and no envelope ever carries it. Each replacement is a stripped record on
media_report with "source": "preserve_media".
Setting up a store¶
Both setups below save the same session; the difference is where the bytes go and what a restore needs.
Without a store (the default). Nothing to configure. The envelope is self-contained: inline media
is written as it is stored, and Google upload bytes are kept in the media table up to
media_max_inline_bytes per part. This is enough for text-only and small-image sessions, for
short-lived sessions, and for a single process that reads its own checkpoints. The cost is size: a
PDF attached with files= is roughly its size times 1.37 in the envelope, on every save, and a
Google audio or video upload above the cap is not kept at all.
from doc_processing_toolkit import Agent, AgentSession
agent = Agent(model="gemini-3.5-flash-lite", instructions="You transcribe and summarise.")
session = agent.session()
await session.run("Summarise this call.", files=["call.wav"]) # uploaded to the Files API
session.to_json(path="checkpoints/call.json") # the bytes ride the media table (≤ 8 MiB)
restored = AgentSession.from_json("checkpoints/call.json", agent=agent)
With a store. Configure one on the agent and every session of that agent externalises on save
and re-inlines on restore, without naming the store again. DirectoryMediaStore(path) is bundled:
one file per blob, named <sha256>.<ext>, so a blob shared by ten checkpoints is written once, and
an envelope and its directory can be moved together. A directory path passed as media_store becomes
one.
from doc_processing_toolkit import Agent, AgentSession
from doc_processing_toolkit.agent.media_policy import DirectoryMediaStore
store = DirectoryMediaStore("checkpoints/media") # or simply media_store="checkpoints/media"
agent = Agent(model="claude-haiku-4-5", instructions="You review contracts.", media_store=store)
session = agent.session()
await session.run("Summarise clause 4.", files=["contract.pdf"])
session.to_json(path="checkpoints/contract.json") # "auto" → externalize: the PDF is in the store
restored = AgentSession.from_json("checkpoints/contract.json", agent=agent) # the agent's store
# with an agent that has no store configured, name it on the restore:
restored = AgentSession.from_json("checkpoints/contract.json", agent=other_agent, media_store="checkpoints/media")
for entry in restored.media_report: # empty when every blob was found
print(entry["reason"], entry.get("mime_type"), entry.get("size"))
An object store is the same protocol, written by the host against its own client; none is bundled
because it would put a cloud SDK into every install. put receives the SHA-256, so the key is the
digest and a second put of the same bytes is a no-op; get may raise, and the restore reports it.
class S3MediaStore: # a sketch: complete it with your client
def __init__(self, client, bucket, prefix="sessions/media/"):
self.client, self.bucket, self.prefix = client, bucket, prefix
def put(self, sha256: str, mime_type: str | None, data: bytes) -> str:
key = f"{self.prefix}{sha256}"
self.client.put_object(Bucket=self.bucket, Key=key, Body=data, ContentType=mime_type or "application/octet-stream")
return key
def get(self, ref: str) -> bytes:
return self.client.get_object(Bucket=self.bucket, Key=ref)["Body"].read()
agent = Agent(model="gpt-6-luna", instructions="...", media_store=S3MediaStore(boto3.client("s3"), "my-bucket"))
Which to choose: no store when the envelopes are small and one process owns them; a store when any
turn takes Google audio or video, when the host already has blob storage, or when several services or
tenants share the checkpoints. session.persistence_report gives the numbers to decide with.
Best practices¶
Each item is a recommendation with its reason and the signal that verifies it. None changes the toolkit's behaviour.
- Checkpoint after every completed turn. The resume point after any failure is the last completed
turn, so a checkpoint per turn never loses more than the turn that failed. Verify with
metadata["run_count"]andrun_boundaries: one boundary per saved turn. - Checkpoint inside your
exceptas well. The failed turn's question is onsession.interrupted_turn, and it reaches the next process only if the envelope is written after the failure. - Write atomically.
to_json(path=...)already does (a temporary file plus a rename); a database row should be written in one transaction. A half-written envelope is worse than the previous one. - Choose where envelopes live by size. A text-only session is a few KB per turn; an inline image
or PDF is its size times 1.37 on Anthropic, OpenAI and Fireworks; a Google audio or video upload
adds its bytes to the table up to the cap.
persistence_reportsays what the next save will write: a database column suits text, a file or an object store suits media. - Externalise when media recurs or is large. Any Google audio or video turn, any host that already
has blob storage, any multi-tenant system: configure
media_storeand leave the policy onauto. Inline is fine for one process and small media. - Keep blobs at least as long as every envelope that names them. A store de-duplicates by
sha256, so several envelopes can share a blob; delete one only when no live envelope references it. A deleted blob shows up asmissing_blobinmedia_reporton the next restore. - Keep the bytes if a checkpoint must outlive 48 hours or move between API keys. A Google
file_uriexpires and is bound to the key that uploaded it; themediatable's bytes (or store reference) are what let a restore re-upload. Anover_inline_thresholdentry inmedia_reportis the sign that a part will not survive. - Read the reports after a restore.
prefix_change_report(the instructions or tools moved),conversion_report(what a cross-provider conversion dropped and re-keyed),media_report(what could not be kept),dropped_parts(what the target model cannot accept),interrupted_turn(a question to resend). Under the same model most are empty; under another model or provider they are the diff. - Pause is not a checkpoint.
RunHandle.pause()holds a turn in a live process; a checkpoint is written between turns and resumes in another process. A turn cannot be persisted mid-way. - Upgrade readers before writers. When several services share a store of envelopes, deploy the
toolkit version that reads a new envelope shape before the one that writes it: an older reader
ignores unknown top-level keys, but it cannot re-inline a
$media:marker it does not know. - Store the recipe beside the envelope.
agent.to_config()is the agent as the YAML shapefrom_configreads; write it next to the checkpoint and rebuild withAgent.from_config_string(yaml.safe_dump(recipe), tools=[...]), supplying again what the recipe lists underunserialisable(callables, hooks, a custom store). Through the agent factory,export_agent_session/import_agent_sessioncheckpoint a registered A2A session the same way (MCP). - A store treats a reference as data. The
refa restore hands togetis read from the envelope, soDirectoryMediaStoreaccepts only the<sha256>.<ext>names its ownputreturns and refuses anything with a separator, a..segment or an absolute path (the session records amissing_blob); a save recomputes every digest from the bytes rather than trusting the table. A custom store should do the same: key on the digest, never interpolaterefinto a path. - Treat an envelope as the user's data. It can carry the user's documents and the model's answers about them; store and transmit it as such. The system prompt is never in it by design.
Resuming¶
- Same model.
from_json/from_dictwith the sameAgentconfiguration: the conversation continues. If the instructions or the tool set changed in between, the restore is reported onprefix_changed_on_resume/prefix_change_report, never refused (detecting a changed prefix). - Another model, same provider. Supported and quiet by design. The history reaches the new model unchanged, thinking blocks included (restoring under another model). The envelope records the model that saved it, but it is the provider the restore acts on.
- Another provider. A history made only of plain text turns is provider-neutral and restores
anywhere (a version-3 envelope logs one INFO line saying so). A history that holds one provider's
dialect (Anthropic content blocks, OpenAI Responses items, Gemini
parts, Fireworkstool_calls) is converted: read into a neutral representation (text, media, tool calls, tool results) and written back in the target's dialect, so the conversation continues with its tool rounds and attachments intact. What cannot cross is dropped and reported, never silently: signed thinking (Anthropic), thought parts and signatures (Google), reasoning items (OpenAI),reasoning_content(Fireworks), provider-run tool items and blocks (web search, code execution, MCP), media the target row'sinput_typesdo not list, media whose bytes were not kept, and an Anthropic container id. Tool-call ids are re-keyed to the target's convention. Every drop and every re-key is logged at WARNING and recorded onsession.conversion_report, which ridesmetadata["conversion_report"]so it survives the next save;session.converted_fromnames the source dialect. The dialect is read from the messages themselves, so a version-1 or version-2 envelope, which records no provider, converts the same way. A history that cannot be converted at all — one that ends inside a tool round, a tool call without a name, a tool result that matches no call — is refused withSessionCompatibilityErrornaming the turn, and nothing partial is stored; truncate the history before that turn first withtruncate_to/truncate_to_turn.DPT_SESSION_CROSS_PROVIDER_CONVERSION=offrestores the refusal for every provider-native history (Configuration). The conversation-level detail is on Continuing on another provider.
from doc_processing_toolkit import Agent, AgentSession, SessionCompatibilityError
try:
session = AgentSession.from_json(path, agent=Agent(model="gpt-6-luna")) # saved on Anthropic
except SessionCompatibilityError as exc:
print(exc) # only for a history that cannot be converted
else:
print(session.converted_from) # "anthropic"
for entry in session.conversion_report:
print(entry["kind"], entry.get("artefact"), entry["index"], entry.get("detail"))
In place, between turns. session.rebind(new_agent) continues an open session's next turn on
another agent without serialising: same provider, the history is kept as it is; another provider,
the same conversion runs with the same report and the same refusals. The prefix fingerprint is
recomputed against the new agent (prefix_changed_on_resume, prefix_change_report), the Anthropic
container id and the frozen tool snapshot are reset, the Google media table stays, and the hop is
recorded in metadata["served_models"]. A running turn refuses the rebind with RuntimeError.
Whatever the target, read conversion_report, media_report and dropped_parts after the restore
beside prefix_change_report: they say what the conversion left out, which media did not survive
the trip and which parts the target model cannot take (Media in a
checkpoint).
Pausing: in-process and across processes¶
Two different things are called "pause":
- In-process, during a turn:
agent.run_handle.pause()/.resume()/.abort()/.inject()hold the loop at the next safe point (Hooks and Steering). Nothing is serialised; the process must stay alive. - Across processes, between turns: a checkpoint. Save with
to_json()after the turn, stop the process, andfrom_json()in the next one. A turn cannot be paused and persisted mid-way: the provider loop's state (pending tool calls, iteration metrics, compaction state) is not part of the envelope. Abort at the safe point and re-ask instead.
Related¶
- Sessions: the session object, history modes, editing history, serialization details.
- Session Checkpointing recipe: the runnable pattern.
- Client Integration: what an integration holds onto.