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_tokensandtotal_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 contextsession.stream(...): the same, as an event stream (see Streaming)session.clear(): clear history while keeping session activesession.truncate_to(...)/session.truncate_to_turn(...): cut history down to a prefixsession.run_boundaries: where each recorded turn's messages sitsession.get_history(): retrieve current message historysession.to_dict()/session.to_json(path=...)andAgentSession.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 changessession.last_turn_results: everyAgentResultthe most recent call produced, in order- Context manager support (
withandasync 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¶
- Per-session override (passed to
agent.session(session_history_config={...})) - Agent-level default (set in
Agent(session_history_config={...})) - Environment variable (
DOC_TOOLKIT_SESSION_HISTORY_MODE) - 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 (
sourceblocks and data URLs) - Google:
inline_datafor small parts and a Files APIfile_urifor uploads (valid for 48 hours). The session records each upload in the envelope'smediatable with its expiry and, withinmedia_max_inline_bytesor in amedia_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 aMediaStoreor becomes a placeholder (Setting up a store) - Set
preserve_media: Falseinsession_history_configto replace every media part by a text placeholder when the turn is stored, so no later turn replays it; each replacement is recorded onsession.media_reportwith"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(orto_dict/from_dict) after every completed turn for cross-process continuation; see History and Checkpointing.

Figure: session-aware agent lifecycle.
Related¶
- History and Checkpointing — where history lives, checkpoint cadence, what an error leaves, resuming under another model or provider
- Session Checkpointing Recipe
- Session Memory Recipe
- Session Hygiene Recipe — truncate, restore across a prompt
change, read
dropped_parts, count requests, recover undelivered messages - Context Compaction — how compaction interacts with session history