Skip to content

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.messages is a plain list of provider-native dicts, and session.metadata is the accounting block (tokens, cost, run count, compaction counters, run boundaries, parked late injections, served models). Both belong to the AgentSession object 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 its complete event 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"] and run_boundaries: one boundary per saved turn.
  • Checkpoint inside your except as well. The failed turn's question is on session.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_report says 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_store and leave the policy on auto. 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 as missing_blob in media_report on the next restore.
  • Keep the bytes if a checkpoint must outlive 48 hours or move between API keys. A Google file_uri expires and is bound to the key that uploaded it; the media table's bytes (or store reference) are what let a restore re-upload. An over_inline_threshold entry in media_report is 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 shape from_config reads; write it next to the checkpoint and rebuild with Agent.from_config_string(yaml.safe_dump(recipe), tools=[...]), supplying again what the recipe lists under unserialisable (callables, hooks, a custom store). Through the agent factory, export_agent_session / import_agent_session checkpoint a registered A2A session the same way (MCP).
  • A store treats a reference as data. The ref a restore hands to get is read from the envelope, so DirectoryMediaStore accepts only the <sha256>.<ext> names its own put returns and refuses anything with a separator, a .. segment or an absolute path (the session records a missing_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 interpolate ref into 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_dict with the same Agent configuration: the conversation continues. If the instructions or the tool set changed in between, the restore is reported on prefix_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, Fireworks tool_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's input_types do 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 on session.conversion_report, which rides metadata["conversion_report"] so it survives the next save; session.converted_from names 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 with SessionCompatibilityError naming the turn, and nothing partial is stored; truncate the history before that turn first with truncate_to / truncate_to_turn. DPT_SESSION_CROSS_PROVIDER_CONVERSION=off restores 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, and from_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.