Threading Model of the OpenAI Agents SDK: Sessions, Resumptions, and Re-Runs
How the OpenAI Agents SDK threads conversation state: sessions, interrupted-run resumptions, compaction, and the SQLite trust boundary.
Published on • October 18, 2026
AI Assistant

Every multi-turn agent has a threading model, whether or not you design one. In the OpenAI Agents SDK, the thread is explicit: a session is a persistent memory layer that maintains conversation history across runs, and the runner — not your code — decides what the model sees each turn. Get the model right and resumptions, corrections, and re-runs are four lines of code. Get it wrong and you are hand-rolling .to_input_list() calls until history double-appends in production.
What a Session Actually Does
The sessions guide defines the contract precisely. When session memory is enabled:
- Before each run, the runner retrieves the conversation history for the session and prepends it to the input items.
- After each run, all new items generated during the run — user input, assistant responses, tool calls — are stored in the session.
- Each subsequent run with the same session includes the full conversation history.
from agents import Agent, Runner, SQLiteSession
agent = Agent(name="Assistant", instructions="Reply very concisely.")
session = SQLiteSession("conversation_123") # in-memory
result = await Runner.run(agent, "What city is the Golden Gate Bridge in?", session=session)
result = await Runner.run(agent, "What state is it in?", session=session) # remembers turn 1
The critical design constraint: a session is client-side memory you own. Within one run it cannot be combined with the run-level continuation options conversation_id, previous_response_id, or auto_previous_response_id — those are OpenAI server-managed continuation. Layering both produces ambiguity about which history is authoritative. Pick one: server-side continuity when you want OpenAI to store the thread, a session when you want the store — and the retention policy — under your control.
Turn Boundaries: What Gets Retrieved and Stored
Two knobs control how history threads into each turn.
Retrieval limits. SessionSettings(limit=None) (the default) retrieves all available items; SessionSettings(limit=N) retrieves only the most recent N. Cap it per run without changing the session’s default:
from agents import RunConfig, SessionSettings
result = await Runner.run(
agent,
"Summarize our recent discussion.",
session=session,
run_config=RunConfig(session_settings=SessionSettings(limit=50)),
)
Merge control. The runner normally prepares model input as session history, then new turn input. RunConfig.session_input_callback intercepts exactly that merge step:
def keep_recent_history(history, new_input):
return history[-10:] + new_input # custom pruning, old items not re-persisted
result = await Runner.run(
agent, "Continue from the latest updates only.",
session=session,
run_config=RunConfig(session_input_callback=keep_recent_history),
)
The subtlety that saves you: the callback receives copies of both lists, and the SDK still persists only items that belong to the new turn. Reordering or filtering old history for the model does not re-save old items as fresh input. For a final pass right before the model call, the docs point to call_model_input_filter instead.
Resumptions: Interrupted Runs Pick Up the Same Thread
Approval-gated workflows pause mid-run. The SDK’s resumption path keeps the thread intact by re-entering the run with state plus the same session:
result = await Runner.run(
agent, "Delete temporary files that are no longer needed.", session=session
)
if result.interruptions:
state = result.to_state()
for interruption in result.interruptions:
state.approve(interruption)
result = await Runner.run(agent, state, session=session)
The sessions guide is explicit about the identity requirement: resume “with the same session instance (or another instance configured with the same session ID and the same underlying storage backend) so the resumed turn continues the same stored conversation history.” A SQLiteSession("conversation_123") pointing at a different database file is a different thread, and the resumed run will not see the pre-interruption history. This is the most common production bug in this area — swapping session backends between API replicas without a shared store silently forks conversations.
Re-runs and corrections use the same thread primitives from the other direction. session.pop_item() removes and returns the most recent item; pop the assistant response and the user message, then run the corrected question:
assistant_item = await session.pop_item()
user_item = await session.pop_item()
result = await Runner.run(agent, "What's 2 + 3?", session=session)
For multi-tenant re-runs, note that several agents can share one session — a support agent and a billing agent pointed at the same SQLiteSession("user_123") both see the same conversation history, which is how you implement handoffs without a handoff protocol.
Choosing the Backend Is Choosing the Threading Topology
The built-in implementations differ in where the thread lives, which determines who can share it:
| Backend | Threading topology |
|---|---|
SQLiteSession | One process, file or in-memory (SQLiteSession("id", "conversations.db")) |
AsyncSQLiteSession | Same, via aiosqlite |
RedisSession | Shared across workers/services; from_url(...) owns the client, close() makes it terminal |
SQLAlchemySession | Any SQLAlchemy-supported DB; the production default for existing databases |
MongoDBSession | Multi-process storage; batches write with a monotonically increasing seq for ordering |
DaprSession | Cloud-native sidecars, with TTL and consistency controls |
OpenAIConversationsSession | Server-managed; resume by passing conversation_id="conv_123" |
EncryptedSession | Wrapper adding encryption + TTL over any backend |
AdvancedSQLiteSession | Branching (create_branch_from_turn) and usage analytics (store_run_usage) |
For genuinely long threads, OpenAIResponsesCompactionSession wraps another session and compacts stored history via the Responses API (responses.compact), automatically after each turn when should_trigger_compaction allows it. Two operational facts from the docs you must plan around: auto-compaction runs inside the run — “the SDK waits for it before Runner.run(...) returns or the streamed event iterator closes,” so stream_events() can stay open seconds after the last token — and compaction failure triggers an automatic restore of the previous history before the error surfaces. For latency-sensitive streaming, disable auto-compaction and call run_compaction({"force": True}) during idle time.
The SQLite Trust Boundary
The docs are unusually candid here, and it is the part teams skip. SQLiteSession “assumes that the application trusts the SQLite database and its storage. The backend does not authenticate stored rows or detect external edits, deletions, reordering, or replay.” Concretely: get_items() selects rows by session_id, orders by insertion IDs, and decodes message_data as JSON — so anyone who can write the database file can inject history that the agent will treat as its own memory on the next run. A session ID selects history; it does not authenticate a user or authorize access to that history.
Your application must therefore authorize every session access, restrict the database file and its backups, and — if storage is shared or untrusted — add integrity controls. EncryptedSession gives confidentiality and TTL expiry but “does not verify the completeness or order of conversation history and accepts unencrypted items.” Tamper detection needs storage controls or a custom backend.
Custom Threads: The Session Protocol
Everything above implements the same protocol. You define a class that structurally follows Session — no base class required — with a session_id, optional session_settings, and four async methods: get_items(limit=None), add_items(items), pop_item(), and clear_session().
For tenant routing or per-request authorization, add an explicit wrapper: RunContextWrapper[Any] | None = None keyword to all four methods; the SDK passes the active run context only when every method declares it — “a generic **kwargs parameter does not satisfy this signature check.” That is the hook for building the tenant isolation the built-in SQLite backend deliberately does not provide.
Conclusion
The Agents SDK’s threading model is a clean split: the runner owns turn assembly, the session owns history, and you own the thread’s identity, storage, and access control. Use one session per conversation, resume interruptions with the same instance and backend, prune retrieval with SessionSettings and session_input_callback instead of mutating stored history, and treat the SQLite trust boundary as a real security requirement rather than documentation fine print. Do that, and corrections, re-runs, and resumed approvals stop being edge cases — they are just more turns on the same thread.