Skip to content

Sessions

AgentSession provides stateful, multi-turn conversation support on top of an Agent.

What sessions track

  • Provider-native message history — full conversation including tool calls, tool results, and reasoning items (not just text-only user/assistant pairs)
  • Aggregate metadata: tokens (including total_cached_input_tokens and total_cache_write_tokens), cost, run count, timestamps, compaction events, retry totals (total_retry_cost, total_retry_count, total_retry_input_tokens, total_retry_output_tokens)
  • Session lifecycle (open / closed)
  • Session history configuration

Session retry totals became real in 0.3.0

total_retry_cost and its siblings used to be summed from each run's per-iteration retry fields, which are reserved and hardcoded to zero on every provider — so a session that retried heavily still reported zero. Sessions now accumulate the run-level retry fields from AgentResult, so these totals reflect actual retry spend. See Cost Tracking.

Core operations

  • session.run(...): run with history-aware context
  • session.stream(...): the same, as an event stream (see Streaming)
  • session.clear(): clear history while keeping session active
  • session.truncate_to(...) / session.truncate_to_turn(...): cut history down to a prefix
  • session.run_boundaries: where each recorded turn's messages sit
  • session.get_history(): retrieve current message history
  • session.to_dict() / session.to_json(path=...) and AgentSession.from_dict(...) / AgentSession.from_json(...): serialize/restore session state (the envelope, see History and Checkpointing)
  • session.rebind(new_agent): continue the next turn on another agent, in place, converting the history when the provider changes
  • session.last_turn_results: every AgentResult the most recent call produced, in order
  • Context manager support (with and async with)

Provider-native history

Session history stores the complete provider-native conversation from each turn, extracted from AgentResult.messages. This includes:

  • User prompts (in provider-specific format)
  • Assistant responses (including text and tool-call structures)
  • Tool call requests and tool results
  • Reasoning/thinking items (where supported)

All SDK objects are normalised to JSON-serialisable dicts when stored, in the provider's own message shape (Anthropic content blocks, OpenAI Responses items, Gemini parts), so the full conversation structure survives a save and a restore. A history of plain text turns is provider-neutral and replays anywhere; one that holds tool rounds, reasoning or media is provider-native, and crossing to another provider goes through the conversion, which reports what could not cross.

This approach gives models much better context than text-flattened history, because they receive tool interactions in their native API format rather than as formatted text blocks.

Session history configuration

Control what is stored in session history via session_history_config:

agent = Agent(
    model="claude-sonnet-5",
    session_history_config={"mode": "no_reasoning", "preserve_media": True},
)

Modes

Mode Description Use case
full (default) Keeps everything Maximum context fidelity
messages_only Keeps the questions and answers only: drops tool calls, tool results and reasoning (a turn mixing reasoning with text keeps its text) Plain conversational memory without tool traffic
no_tool_results Replaces each tool result's content with a short placeholder, keeping the tool call and the result message Reduce token usage when tool outputs are large
no_reasoning Excludes reasoning/thinking blocks Save tokens on models with extended thinking
compact_tool_results Compresses tool output content Balance between context and token usage
text_summary Text-only summary format Minimal token usage

compact_tool_results truncates a tool result whether its content is a plain string or a list of typed text blocks ({"type": "text", "text": ...}). The list shape — which Anthropic tool_result blocks and Fireworks role: "tool" messages both use — previously slipped through untouched, so the largest tool outputs were the ones the mode failed to compact. Cap the per-result size with compact_tool_results_max_chars (default 500).

no_reasoning now strips a turn that mixes reasoning with anything else

no_reasoning removes the model's reasoning from the history you replay. Before this release it only removed a turn that was reasoning and nothing else — so on every thinking model, where reasoning arrives alongside the answer or alongside a tool call, it quietly did nothing and you replayed and paid for the reasoning anyway. It now strips such a turn block by block. Blocks are removed whole and never edited: a thinking block is signed, and there is no version of "shorten the reasoning" that survives a round trip.

Two exceptions. A history that ends inside a tool round keeps that round exactly as it is, because Anthropic rejects a modified latest assistant message while tool results are being returned — which also means stored history can retain one turn's reasoning. Gemini turns keep their signed parts and lose their thought summaries, as the Google note below says.

Expect smaller requests and different request bodies. Because the mode's whole purpose is to change the history you replay, a no_reasoning session is not append-only across turns: if you rely on Anthropic prompt caching, expect the cached prefix to break at each turn boundary. messages_only had the same blind spot until this release — a mixed thinking-plus-text turn kept its thinking there. It now strips such a turn part by part exactly as no_reasoning does (and drops a turn that carried only redacted_thinking, and the Fireworks reasoning_content field). On the preserved-thinking rows that removes the thinking of each retained final answer from the messages_only history, so those requests get smaller.

The history a session replays is filtered for the active mode before it reaches the provider, so under messages_only, no_tool_results or no_reasoning fewer messages are sent than the session holds. The toolkit records the number it actually sent and skips exactly that many of the messages the provider echoes back. Before this release that count was tracked only for Google; on the other three providers a filtering mode made the session skip the wrong number of echoed messages, which deleted the turn's own question from history or duplicated an earlier turn. Sessions using a filtering mode therefore now store the full turn — an application that had adapted to the missing question will see real history.

The mode also holds on a turn where Level 1 compaction fires. The history is re-prepared after the compaction rewrites it, and that re-prepare used to skip the filter, so on exactly the compacting turns no_reasoning, no_tool_results, messages_only and compact_tool_results silently stopped applying to the request (thinking was replayed, a tool_result_clearing placeholder or a raw result reached the model). The filter is now re-applied after the compaction. Those requests are smaller under no_reasoning; under no_tool_results each replayed result carries the mode's own placeholder, a few characters longer than the compaction one it replaces.

text_summary stores each turn's bare question. The provider is sent the prior conversation flattened into the first part of the user message and echoes that message back; the session used to store the echo whole, so turn 3's flattened context contained turn 2's entire flattened prompt and the stored history (and every later request) grew quadratically with the number of turns. Only the composed context part is dropped — the question and any file parts are kept. Envelopes saved before this release restore unchanged; their already-nested entries age out as the conversation moves on.

The echo can also come back shorter than what was sent — Agent(prefix_strip_fallback=True) drops a turn left with no content once its thinking is stripped. When skipping the recorded count would start past this turn's own question, the session re-anchors on that question instead and logs a warning, so the question is never lost; before this release it was. The re-anchor can only keep more messages than the plain skip, never fewer.

Filtering modes and preserved thinking

no_reasoning and messages_only remove reasoning at the moment a turn is stored, and the removal is permanent. On a preserved-thinking model that includes blocks a later model could still have read after a model switch — the API would have dropped only the ones the new model cannot read.

messages_only on Google

Before this release messages_only deleted every message of a Gemini session, not only its tool traffic: the filter tested whether a part carried a function_call key, and Part.model_dump() emits every field of a part (16 of them on google-genai 2.24.0, 17 on 2.25.0), so a plain question and a plain answer matched too. Such a session ran with no memory at all — per session, per agent, or through DOC_TOOLKIT_SESSION_HISTORY_MODE=messages_only.

It now keeps every user question and every model answer and drops only tool calls and tool results, which is what the mode documents. That is a real behaviour change for anyone who had been relying, knowingly or not, on that amnesia, and it raises the input-token cost of those sessions. full, no_tool_results and compact_tool_results are byte-identical on the wire.

no_reasoning (and messages_only) drop a stored Gemini turn's thought-summary parts (thought: true) and keep every other part, signed parts included. Gemini's signature rides the answer or function-call part it belongs to, not the summary beside it, so the signed part replays intact. The mode used to be a no-op on Google.

Google sessions store the model's own response object

A Gemini session stores the final answer as the Content the API returned, not as a plain-text assistant message. This preserves the answer's thought_signature, which Google recommends returning so the model "maintains high-quality reasoning" — it is not validated, so omitting it was never an error, only a quality cost.

Two things follow. With thinking enabled each stored answer replays its thought summary, which adds roughly 200–1,700 input tokens per stored turn and compounds across a session, so compaction fires sooner; DPT_GOOGLE_RETAIN_FINAL_TURN=off restores the previous plain-text record (see Configuration and Environment Variables). The stored answers are provider-native, so messages_only keeps them as model Content too, minus the thought-summary part.

When such a history is replayed to a different provider, the toolkit renders its text-only turns — user and model alike — back to the neutral {"role": ..., "content": ...} form. A history containing tool calls is Gemini-shaped and is converted on a cross-provider restore (Continuing on another provider).

Anthropic preserved-thinking sessions store the answer's signed thinking

On claude-fable-5-1, claude-opus-5-5, claude-sonnet-5-5 and claude-haiku-5-5 — the rows whose registry entry sets produces_preserved_thinking_blocks — a session stores the final answer of a turn that thought as the block list the model returned: its signed thinking / redacted_thinking blocks plus the text, on the tool loop and on a no-tools run alike. The next turn replays those blocks, which is the reasoning continuity Anthropic's preserved-thinking guidance asks for. Every other Anthropic row, and a turn that carried no thinking, stores plain text exactly as before.

It costs the same way the Gemini record does: each stored answer's thinking is re-sent as input on every later turn, so compaction fires sooner. DPT_ANTHROPIC_RETAIN_FINAL_TURN=off restores the plain-text record (see Configuration and Environment Variables). On a no-tools run the stored user message is also the provider's block form rather than a plain string. Under no_reasoning the stored answer keeps only its text, and the turn's final tool round has its thinking stripped like any other closed round.

Configuration precedence

  1. Per-session override (passed to agent.session(session_history_config={...}))
  2. Agent-level default (set in Agent(session_history_config={...}))
  3. Environment variable (DOC_TOOLKIT_SESSION_HISTORY_MODE)
  4. Built-in default ({"mode": "full", "preserve_media": True})

Multimodal sessions

Media content (images, documents, audio, video) is preserved across session turns:

  • Binary data (bytes) is automatically base64-encoded when normalised for session storage
  • OpenAI/Anthropic/Fireworks: inline base64 (source blocks and data URLs)
  • Google: inline_data for small parts and a Files API file_uri for uploads (valid for 48 hours). The session records each upload in the envelope's media table with its expiry and, within media_max_inline_bytes or in a media_store, its bytes, and re-uploads a dead URI before the next turn (Media in a checkpoint)
  • On save, media_policy (auto / inline / externalize / strip) decides whether media stays inline, goes to a MediaStore or becomes a placeholder (Setting up a store)
  • Set preserve_media: False in session_history_config to replace every media part by a text placeholder when the turn is stored, so no later turn replays it; each replacement is recorded on session.media_report with "source": "preserve_media". The default keeps media.

Context window impact

Large media content (e.g., high-resolution images) encoded as base64 can significantly increase token usage. Consider using compact_tool_results or no_tool_results mode, or enabling compaction, for media-heavy sessions.

Practical behavior

  • New user messages are appended before each run.
  • The complete provider-native response (including tool calls and results) is appended after completion.
  • The run's final assistant reply is appended too, so the answer the caller received is part of the history the next turn sees.
  • When intra-run compaction occurs, the session replaces its stored history with the compacted result (avoiding stale prefix deduplication issues).
  • Metadata accumulates per run.

The final reply reached history in 0.3.0

A provider loop's message list holds only what was circulated to the API, and the last assistant answer is never sent anywhere — so none of the four loops appended it. Sessions inherited that gap: every turn's actual answer was missing from history, later turns could not refer back to what the model had said, and compaction summarized a conversation containing questions but no replies. The session now appends the final reply itself, and skips the append when compaction has already put it there, so it is never stored twice.

Editing history

A session's history is a flat, provider-native list, and until this release the only way to change it was clear(). truncate_to(message_count) cuts it down to a prefix, and truncate_to_turn(n) does the same in the units you actually think in — turns — by resolving n through the recorded run boundaries. Both return the number of messages actually kept.

session.truncate_to_turn(2)     # keep the first two recorded turns
session.truncate_to(10)         # or cut to an exact message count

Both are pure prefix truncations: nothing that survives is rewritten. That matters more than it sounds. A retained thinking block, redacted_thinking block or Gemini thought_signature is signed by the provider, and a history that edits one is a history the model may refuse. So when the count you asked for would leave a tool call without its result, the toolkit moves the cut point backward rather than repairing the messages — iteratively, because removing one round can strand the call before it. Pass drop_orphans=False to cut at exactly the count and take legality on yourself.

A cut may legally land immediately after a complete tool round whose answer was cut away: two consecutive user turns are accepted on all four providers. It cannot arise from truncate_to_turn, only from an arbitrary message count.

Metadata survives a cut exactly as it survives clear(). The cumulative counters — total_cost, total_tokens, run_count, compaction_count — are the history of what this session spent, not of what it currently holds; the requests were made and billed, and rewinding them would make your session report a cost lower than your invoice. metadata["pending_injections"] survives for the same reason: a message you still owe the model does not become delivered because history shrank. The run boundaries are the one piece of metadata made of message indices, so they are the one piece a cut rewinds.

Run boundaries

session.run_boundaries lists one {"run", "start", "end"} record per completed turn, so turn i owns session.messages[start:end] and you never have to parse a provider-native message to find a turn edge. start is recorded rather than inferred from the previous end, because the two differ whenever history moved between turns.

Compaction is the clearest case. It replaces the conversation, so every index recorded before it means nothing afterwards. Rather than leave records pointing at the wrong messages the toolkit empties the list and sets metadata["boundaries_reset_at_run"] to the run that did it — so run_boundaries == [] with that key present reads as "the turns before run N are gone", not as "nothing has run yet". len(run_boundaries) is therefore the number of turns still addressable in stored history, while metadata["run_count"] is the number of turns ever run; after a compaction or a truncation the two differ on purpose. clear() resets the boundaries and stamps the key too.

Boundaries ride metadata, so they save and restore with the envelope and need no schema_version change — a session saved before they existed simply restores with none, and a record that no longer addresses the restored messages is dropped on load rather than trusted. to_dict() writes a copy, so an envelope you saved does not grow when the session runs another turn, and when the envelope drops the leading system message it rebases every record by the same amount, so each still addresses its own turn in the history it ships with. Before this release the envelope shared the live list and kept the un-rebased indices. A plain-text streamed turn stores a lightweight [user, injected…, assistant] history, so its boundaries describe that shape rather than a provider-native transcript; a streamed turn with output_schema is stored as run() stores it.

The Anthropic tool surface is frozen per session

A session freezes the Anthropic tools array it starts with. The toolkit re-derives tools from the live MCP servers on every run, so a server that stops answering list_tools() mid-session used to shrink the array — a change the model sees, which also changes tool routing for the dropped names, and on a preserved-thinking model a prefix change that invalidates every replayed thinking block. The array a session started with is now reused, each change is recorded in AgentSession.tool_surface_drift ({event, failed_servers, added, removed, redefined}), and with strict_prefix_binding=True drift raises instead of being reported. A first-run failure keeps the old behaviour — there is nothing yet to fall back to — and says so loudly.

The freeze is per session. A plain Agent.run() still derives its tools fresh, so a newly added tool can appear between two independent runs, and a resumed session re-freezes on its first request, because live MCP sessions do not survive to_dict(). The frozen array is handed to the Agent only for the session's own run and cleared when that run ends; before this release it stayed on the Agent, so a later plain agent.run() — or a second session on the same Agent — re-sent the first session's tools, and failed outright under strict_prefix_binding. A genuinely changed tool surface on resume is reported by the envelope's prefix fingerprint instead.

Late hook messages: surface or new turn

A hook can queue a message mid-run, but a message that arrives after the run's last turn boundary cannot be delivered inside that run — appending a user message after the model's final answer is structurally a new turn. AgentSession decides what happens to it:

session = agent.session(late_injection_policy="new_turn", max_follow_up_turns=2)
Policy Behaviour
"surface" (default) The text is parked in metadata["pending_injections"] and the host owns delivery. Behaviour-neutral: a session that never sees a late message carries exactly the metadata keys it carries today.
"new_turn" The session sends it as the next turn's user message, then returns that turn's result.

Under "new_turn" every follow-up is an ordinary turn — its own request, its own AgentResult, its own cost records, its own run_count increment. run() therefore returns the last turn's result and result.cost is that turn's cost alone; session.last_turn_results holds the whole chain in order, so the honest total is sum(r.cost for r in session.last_turn_results), which equals the metadata["total_cost"] delta. On an ordinary call last_turn_results has length 1.

Four guards bound it: max_follow_up_turns (default 1) caps follow-ups per call, no follow-up starts after a failed turn, an abort or a raised exception, and a follow-up whose text repeats the message that produced it is refused as a stuck host inbox. Anything not sent stays on metadata["pending_injections"] with a warning — never dropped. 0 behaves like "surface".

Failed turns and concurrent turns

A turn that raises, or that returns an error-shaped result with no transcript and no answer, leaves every completed turn in place and moves its own question to metadata["interrupted_turn"] (session.interrupted_turn) instead of leaving it in history unanswered; the record survives the envelope and is cleared when the next turn starts. A second run() / stream() while a turn is running raises RuntimeError rather than interleaving into the same history. Both are described on History and Checkpointing; DPT_SESSION_INTERRUPTED_TURN=off restores the dangling message.

Read-only views: session.late_injection_policy, session.max_follow_up_turns and session.pending_injections. Both settings travel in the session envelope, so a restored session keeps the policy it was created with; an envelope written without them restores the defaults, and an unrecognised value degrades to the default with a warning.

A streamed follow-up sees less history than a run() follow-up

A plain-text streamed turn stores a lightweight [user, injected…, assistant] history. A "new_turn" follow-up on the streamed path therefore sees the previous turn's answer but not the tool calls and tool results that produced it, and may call one of those tools again. The run() path stores the full provider-native transcript and is unaffected, and so is a streamed turn with output_schema, which is now stored exactly as run() stores it.

On the event stream, a non-final turn ends with turn_complete and the next opens with turn_start — see Streaming.

Serialization

to_dict() serializes the session's conversation and accounting:

data = session.to_dict()
# Returns: {
#     "schema_version": 3,
#     "session_id": "...",
#     "provider": "anthropic",       # who saved it (v3)
#     "model": "claude-haiku-4-5",
#     "toolkit_version": "0.5.2",
#     "saved_at": "2026-09-24T10:00:00+00:00",
#     "sdk": {"name": "anthropic", "version": "..."},
#     "media_policy": "inline",      # the policy this save applied (auto → inline or externalize, strip)
#     "media": {},                   # Google uploads (bytes or store ref + expiry) and externalised parts
#     "media_report": [],            # what a save or restore could not keep
#     "messages": [...],             # Full provider-native history
#     "metadata": {...},             # Tokens (incl. cache totals), cost, compaction counts,
#                                    # "pending_injections" when a late message was parked,
#                                    # "run_boundaries", and "served_models" (which model answered each run)
#     "session_history_config": {...},
#     "late_injection_policy": "surface",
#     "max_follow_up_turns": 1,
#     "prefix_fingerprint": {"system": "<sha256>", "tools": "<sha256>"},
#     "reuse_anthropic_container": False,
#     "anthropic_container_id": None,
#     "closed": False,
# }

Restore a session with from_dict():

restored = AgentSession.from_dict(data, agent=agent)
result = await restored.run("Continue where we left off.")

to_json() / from_json() are the same envelope as JSON text or a file (to_json(path=...) writes atomically), and session.persistence_report measures what to_dict() would write. The checkpoint pattern, the resume rules and what a failed turn leaves behind are on History and Checkpointing.

The serialized data includes the provider-native message structures. Media follows the media policy: inline base64 on Anthropic, OpenAI and Fireworks up to media_max_inline_bytes under the default auto, Files API references plus the envelope's media table on Google; under externalize a payload is replaced by a $media:<ref> marker and lives in the store, and under strip by a placeholder. The envelope is a detached copy: messages are normalised to JSON-clean values and metadata is deep-copied, so editing the saved dict never edits the live session.

A partial metadata block is merged over the defaults rather than replacing them: an envelope you trimmed to, say, {"total_cost": 0.25} restores with every other counter at its default. Before this release it restored and then raised KeyError: 'compaction_count' on the next run(). A complete block restores exactly as saved.

What is not serialized

The envelope carries the conversation, never the agent's instructions. A session envelope is routinely written to a database, handed to another process, or passed between parties; a system prompt is the one part of a run that is often confidential, and it is always reconstructible from the Agent that loads the envelope. The same applies to tool definitions.

Instead the envelope stores a prefix fingerprint — SHA-256 digests of the system prompt and of the name-sorted tool surface. Enough to notice that the request prefix moved, not enough to reconstruct it. An Agent with truncated_result_read_chars set counts the built-in read_truncated_result tool in that surface (built_in:read_truncated_result, 0.5.1) unless max_tool_result_chars=0 or a forcing tool_choice keeps the tool off the list, so restoring under an agent that turns the setting on or off reports tools as changed.

Nor does it carry the full text of tool results that max_tool_result_chars cut. With Agent(truncated_result_read_chars=...) a live session keeps them in memory so the model can read on with read_truncated_result in a later turn; a restored session starts without them, and asking for the rest of an old result answers that it is no longer available (the restore retires the ids its history names, so none can reach a newer result). The slices the model did read are ordinary tool results, so they are in the history and the envelope like any other. clear() and close() release the kept text.

Envelope versioning

schema_version is an integer, and a reader accepts every version it knows:

Version Meaning
(key absent) Implicit version 1 — any envelope written before versioning. It is migrated on load.
2 Carries schema_version and prefix_fingerprint.
3 The current shape: additionally records provider, model, toolkit_version, saved_at, sdk, media_policy, media and media_report. Every new key is top-level and optional, so a version-2 reader replays an inline version-3 envelope exactly as a version-2 one; an externalised or stripped envelope needs a version-3 reader, which is the one that can read the $media: markers.
Anything higher Loaded anyway, with a warning, ignoring the keys this reader does not recognise.

Refusing to load a newer envelope would strand a conversation whose messages the reader can read perfectly well, so from_dict() never fails on a version. restored.restored_from_schema_version tells you which version the envelope declared. The one refusal from_dict() makes is not about versions: a history written in one provider's dialect that cannot be converted for an Agent on another provider (it ends inside a tool round, a call has no name, a result has no call) raises SessionCompatibilityError; every other provider-native history is converted (Continuing on another provider).

Detecting a changed prefix on resume

A conversation saved last week may be restored by an Agent whose instructions or tool set have since changed. That is legitimate — only your application knows whether it was deliberate — so the toolkit reports it rather than refusing:

restored = AgentSession.from_dict(data, agent=agent)

if restored.prefix_changed_on_resume:
    report = restored.prefix_change_report
    report["changed"]   # ["system"], ["tools"], or both
    report["saved"], report["current"]   # the two fingerprints

One INFO line is logged as well. It matters most on Anthropic, where a preserved thinking block is bound to the conversation prefix it was produced under: restoring into a different prefix is a fresh-context restoration, not a continuation.

Connection state is excluded on purpose

MCP tools exist on the loader only while its sessions are open, so a session saved mid-run and restored in a fresh process would otherwise always look changed. Each MCP server contributes a stable identity to the fingerprint instead of its current tool list — which means a server that silently changes which tools it serves does not move this fingerprint.

Restoring a session under another model

Restoring an envelope into an Agent on a different model of the same provider is supported, and it is quiet by design: the prefix fingerprint covers only system and tools, so no warning fires. The envelope records the model that saved it (version 3), but that is provenance; it is the provider a restore acts on, and a history in another provider's dialect is converted rather than sent as it is (Continuing on another provider). The stored history reaches the new model unchanged, thinking blocks included (a thinking-off run on claude-sonnet-5-5 or claude-haiku-5-5 is the one exception: it leaves earlier turns' thinking blocks out of its requests), and on Anthropic the API drops whatever the new model cannot read — see who reads whose blocks. That drop is reported in input_transformations / model_binding_mismatches only when the new request carries the binding beta: on a thinking run of the four gated rows, or when the new Agent opts in with anthropic_block_binding="drop_block". The same holds for a host that hands a live session to another Agent.

Three things can leave thinking out of what a session stores, and each is your choice. The filtering history modes no_reasoning and messages_only (Modes) strip the stored history itself (under no_reasoning a history that ends inside a tool round keeps that round, as its warning says). Agent(prefix_strip_fallback=True) and a thinking-off run on claude-sonnet-5-5 or claude-haiku-5-5 (Thinking off on a model that checks the thinking prefix) normally change only what the run sends: the stored history keeps its earlier thinking blocks, because the session stores the new tail of the turn. Only on a run that also compacted does the session adopt the loop's already-stripped message list as its history, and then the strip is permanent.

Continuing on another provider

A restore — from_dict, from_json — under an Agent on another provider, or a session.rebind(new_agent) across providers, converts the stored history. It is read into a neutral representation and written back in the target's dialect; the converted history is what the session stores from then on, and the conversation continues with its tool rounds and attachments.

Kept Dropped and reported Refused
user text and media (by bytes: base64 on Anthropic, OpenAI and Fireworks, inline_data on Google; a Google upload's bytes come from the envelope's media table), assistant text, tool calls (name, arguments) and their results, structured answers (JSON text) Anthropic thinking / redacted_thinking, Google thought parts and thought_signatures, OpenAI reasoning items (with their encrypted_content), Fireworks reasoning_content; provider-run tool items and blocks (web search, code execution, MCP, computer use); media the target row's input_types do not list (a text placeholder takes its place); a Google upload whose bytes were not kept; a YouTube reference outside Google; an Anthropic container id a history that ends inside a tool round, a tool call without a name, a tool result that matches no call — SessionCompatibilityError names the turn and nothing partial is stored

Tool-call ids are re-keyed to the target's convention (toolu_…, call_…) and every re-key is recorded. Every entry of the report is also logged, aggregated per kind, at WARNING:

restored = AgentSession.from_json("checkpoints/review.json", agent=Agent(model="gemini-3.5-flash-lite"))
restored.converted_from             # "anthropic" (None when nothing was converted)
restored.conversion_report          # rides metadata["conversion_report"]; grows across conversions
# [{"kind": "dropped", "artefact": "thinking", "index": 1, "detail": "2 signed thinking block(s)",
#   "from": "anthropic", "to": "google", "at": "2026-09-24T10:00:00+00:00"},
#  {"kind": "rekeyed", "index": 1, "name": "add", "from_id": "toolu_01…", "to_id": "call_…", ...}]

index is the position of the message in the stored history the conversion read. Run boundaries index that list too, so they are not carried across a conversion. A plain-text history converts to itself and reports nothing. DPT_SESSION_CROSS_PROVIDER_CONVERSION=off restores the refusal of every provider-native history (Configuration).

Rebinding a live session. session.rebind(new_agent) does the same in place, between turns, for a host that holds an open session and wants the next turn on another model — after a fallback decision, a cost cap, or a user picking another model. Same provider: the history is untouched. Another provider: converted, with the same report. In both cases the prefix fingerprint is recomputed against the new agent, 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"] as an {"event": "rebind"} record. A running turn refuses the rebind with RuntimeError.

Lifecycle guidance

  • Use async with agent.session() for automatic cleanup and clear scope.
  • Use session.clear() when changing topic while keeping the same session object.
  • Persist with to_json / from_json (or to_dict / from_dict) after every completed turn for cross-process continuation; see History and Checkpointing.

Agent lifecycle and session manager

Figure: session-aware agent lifecycle.