Our agent handles one question well and forgets it instantly. Ask "set the deadline for Project Alpha", then "update the progress", and the second call has no idea which project you meant, because it began with an empty context.
A session fixes that. A session is a container that carries a conversation across several run() calls,
so the agent remembers what was just said.
Every run starts from nothing
Look at why the follow-up fails. Each run() builds a fresh ExecutionContext, works the question, and
returns. The context, with everything in it, is discarded when the call ends. The next run() gets a
brand-new, empty context.
That is the same statelessness a web server has: each request is handled on its own and the server's memory is released when it ends. Web apps solve it with a session, keyed by a cookie, that persists a user's data between requests. An agent needs the same thing, for the same reason.
The Session: a container that persists
A session holds what must survive across run() calls. Two things go in it: the conversation history,
so the agent can continue where it left off, and an arbitrary execution state, so an interrupted task
can be resumed later (the next post uses that half).
class Session(BaseModel):
session_id: str
events: list[Event] = [] # the conversation so far, across runs
state: dict = {} # arbitrary saved state, for resuming
A session is deliberately just data. It knows what it holds and nothing about how it is created, found or saved. Keeping it a plain container is what lets you store it anywhere later, in memory now and in a database in production.
The SessionManager: who creates and saves them
Since the session is only data, something else has to manage its life. That is the SessionManager, and it has three jobs: create a new session and store it, find an existing one by id, and save changes back.
class SessionManager:
def __init__(self):
self._store: dict[str, Session] = {} # swap for a database in production
def create(self) -> Session:
session = Session(session_id=new_id())
self._store[session.session_id] = session
return session
def get(self, session_id: str) -> Session | None:
return self._store.get(session_id)
def save(self, session: Session):
self._store[session.session_id] = session
The store is a dictionary here, which makes the shape obvious. Swapping it for a database changes only
this class, because everything else deals in the SessionManager interface, not the storage.
The Session is a data container. The SessionManager creates, finds and saves it.
Wiring it into the agent
The agent now takes an optional session_id. With one, it loads the session's history into the run's
context before working, and saves the updated history back after.
async def run(self, question: str, session_id: str | None = None) -> AgentResult:
session = self.sessions.get(session_id) if session_id else None
context = ExecutionContext(events=session.events if session else []) # start from history
context.add_event(user_message(question))
result = await self._loop(context)
if session:
session.events = context.events # carry the new turn forward
self.sessions.save(session)
return result
Read the first and last lines together. The run starts from the session's history instead of empty, and it saves the grown history back at the end. Between those two lines, the agent works exactly as before. Continuity is bolted on around the loop, not woven into it.
Watching it hold a conversation
Same session_id across two calls, and the reference resolves.
sid = agent.sessions.create().session_id
await agent.run("Set the deadline for Project Alpha to Dec 27.", session_id=sid)
# -> "Deadline for Project Alpha set to Dec 27."
await agent.run("Update the progress.", session_id=sid)
# -> "Which progress for Project Alpha would you like to update?"
The second call knew "the progress" meant Project Alpha's, because the session carried the first turn
into the second's context. Drop the session_id and the second call would have asked "which project?",
exactly the amnesia we started with.
With a shared session, the second run starts from the first's history. Without one, it starts blank.
What to take from this
- One
run()forgets the last, the same way a web server forgets between requests. A session is the fix, and for the same reason. - Split the concern in two: a
Sessionis a plain data container; aSessionManagercreates, finds and saves it, so the storage can change without touching anything else. - Wire it around the loop: start the run from the session's history, save the grown history back. The agent's core does not change.
A session carries a conversation across calls. It also carries something more powerful: a saved state that lets an agent pause mid-task and resume later. The next post uses that to build a durable human-in-the-loop.