{"slug": "build-a-basic-ai-agent-from-scratch-session-management", "title": "Build a Basic AI Agent From Scratch: Session Management", "summary": "A new installment of the \"Build a Basic AI Agent From Scratch\" blog series adds session management to a Python agent harness, saving each conversation to a JSON file under ~/.agent-harness/memory/sessions/ so work can be resumed after the program closes. The SessionStore builds on the prior part's MemoryStore, recording a session ID, created_at and updated_at timestamps, a summary, and serialized messages, with OpenAI client message objects converted via model_dump(exclude_none=True) to preserve structured tool calls and their result IDs. The store keeps separate messages and transcript lists plus a compactions list and metadata (title, archived, project_root, planning state), and supplies defaults for older session records that lack the new fields.", "body_md": "Previous parts of *Build a Basic AI Agent From Scratch*:\n\nYou can find and clone this code in this blog series' [GitHub repo](https://github.com/rogiia/basic-agent-harness).\n\nIn the previous part we gave our agent long-term memory. It can save project conventions and user preferences, then read those notes the next time it starts. This means we no longer have to explain the same things every time we use it.\n\nBut notes are not enough to continue unfinished work. Imagine asking the agent to migrate a project, closing it for the day, and coming back tomorrow. Remembering that the project uses `uv` is useful, but we also need it to remember what we asked, what it changed, and what still needs to be done.\n\nFor that, we need **sessions**. In this part we will save conversations, find and resume previous work, and recover gracefully after an interruption. In the next part we will compact long conversations without losing the original transcript, and add titles, branches, archiving and exports.\n\nThe implementation is in `agent-sessions/` in the companion repository. It builds on the previous part's `MemoryStore` with a new `SessionStore`. We are still using JSON files as a medium to save agent-related information.\n\nA session is one conversation with the agent. It's the collection of all the user messages, model responses and tool calls that happened during a use of the agent.\n\nUntil now, that conversation has lived in the `messages` list in the harness' memory. To make that session savable and resumable, we will give it an ID and save it to the disk. Closing the program will end the current run, but it will not erase the conversation.\n\nIt's important to note that, although we will be saving sessions in the same directory as memories, agent sessions and memory are two separate things. The memories that an agent collected during a session are not stored in the session information, but separately into the project and user memory files.\n\nOur memory directory now contains a `sessions` directory alongside the notes:\n\n```\n~/.agent-harness/memory/\n  project.md\n  user.md\n  sessions/\n    z7Pq3hM4kA.json\n    t8Nq2vL6bC.json\n```\n\nWe start with the record from `MemoryStore`: a session ID, creation and update timestamps, a summary and the conversation messages. The harness creates it when a new session starts:\n\n``` php\ndef create_session(self, session_id: str, messages: list[dict]) -> None:\n    now = _timestamp()\n    self._write_session(session_id, {\n        \"id\": session_id,\n        \"created_at\": now,\n        \"updated_at\": now,\n        \"summary\": \"\",\n        \"messages\": _serializable_messages(messages),\n    })\n```\n\nThe messages returned by the OpenAI client are objects, not always dictionaries. Before writing them as JSON, we convert them with `model_dump`:\n\n``` php\ndef _serializable_messages(messages: list[Any]) -> list[dict[str, Any]]:\n    result = []\n    for message in messages:\n        if isinstance(message, dict):\n            result.append(message)\n        elif hasattr(message, \"model_dump\"):\n            result.append(message.model_dump(exclude_none=True))\n        else:\n            raise TypeError(f\"Cannot persist message of type {type(message).__name__}\")\n    return result\n```\n\nThis also preserves the structured tool calls. Saving only the visible assistant text would lose which tools the model requested and the IDs connecting those calls to their results.\n\nThere is a problem with keeping only one message list. We want to reduce the context sent to the model when a conversation gets too long, but we don't want that to erase the original conversation from disk.\n\nOur `SessionStore` keeps two lists:\n\n`messages`` transcript`\nWe also keep `compactions`, a list of summary events, and metadata such as `title`, `archived`, `project_root` and planning `state`. When loading an older session without those records (because they were created before this feature existed), we supply defaults for the new fields:\n\n```\nrecord.setdefault(\"transcript\", copy.deepcopy(record[\"messages\"]))\nrecord.setdefault(\"title\", \"\")\nrecord.setdefault(\"archived\", False)\nrecord.setdefault(\"state\", {})\nrecord.setdefault(\"compactions\", [])\n```\n\nThis lets us open sessions created by the previous implementation. Of course, it cannot recover messages that were already trimmed before the upgrade.\n\nFor normal conversation updates, the incoming context must extend the saved context. We append only the new messages to the transcript:\n\n``` python\ndef save_messages(self, session_id, messages):\n    with self.lease(session_id):\n        record = self.load_session(session_id)\n        incoming = _serializable_messages(messages)\n        previous = record[\"messages\"]\n        if incoming[:len(previous)] != previous:\n            raise ValueError(\"Context changed unexpectedly; use compact() to replace it.\")\n        record[\"transcript\"].extend(copy.deepcopy(incoming[len(previous):]))\n        record[\"messages\"] = incoming\n        self._write_session(session_id, record)\n```\n\nThe transcript preserves everything from the conversation; however, tool results still pass through our existing output limits before being added to the message list.\n\nWe will update the file often, so we should avoid overwriting it directly. If the program stops halfway through a write, the JSON might be incomplete and we would lose the saved conversation.\n\nInstead, we write a temporary file and atomically replace the session file:\n\n``` php\ndef _write_session(self, session_id: str, data: dict[str, Any]) -> None:\n    with self.lease(session_id):\n        path = self._session_path(session_id)\n        data[\"updated_at\"] = _timestamp()\n        fd, temp = tempfile.mkstemp(dir=self.sessions_dir, suffix=\".tmp\")\n        try:\n            with os.fdopen(fd, \"w\", encoding=\"utf-8\") as file:\n                json.dump(data, file, ensure_ascii=False, indent=2)\n                file.write(\"\\n\")\n                file.flush()\n                os.fsync(file.fileno())\n            os.replace(temp, path)\n        finally:\n            if os.path.exists(temp):\n                os.unlink(temp)\n```\n\nThe temporary file is created in the same directory, so replacement stays on the same filesystem. We flush and sync its contents before replacing the old file. Until that replacement happens, the previous file stays intact. This protects against partial JSON writes.\n\nAtomic replacement doesn't stop two processes from overwriting each other's work. If we resume the same session in two terminals, each could save a different version of the session into the same session file.\n\nTo avoid this situation, we use a separate `.lock` file and a Linux advisory lock:\n\n``` python\n@contextmanager\ndef lease(self, session_id):\n    path = self._session_path(session_id).with_suffix(\".lock\")\n    if session_id in self._leases:\n        yield\n        return\n    with path.open(\"a\") as file:\n        try:\n            fcntl.flock(file, fcntl.LOCK_EX | fcntl.LOCK_NB)\n        except BlockingIOError:\n            raise ValueError(f\"Session '{session_id}' is in use by another process.\") from None\n        self._leases[session_id] = file\n        try:\n            yield\n        finally:\n            del self._leases[session_id]\n            fcntl.flock(file, fcntl.LOCK_UN)\n```\n\nThe running agent holds this lease for its entire run. Short management operations also acquire it before changing a record. Nested calls from the same store can reuse the lease, but a second process fails immediately instead of waiting.\n\nThe operating system releases the lock when the process exits, including after a crash. We keep the lock file even when deleting a session: removing it could let different processes lock different files under the same name.\n\nThese locks are advisory. Every writer must cooperate, so it's not advisable to run an older implementation of this agent harness without those locks at the same time.\n\nAlso, this implementation uses `fcntl` advisory locks, which are POSIX: they work on Linux, macOS and other Unix-like systems, but not on Windows.\n\nWe also validate session IDs before using them as filenames:\n\n``` php\ndef _session_path(self, session_id: str) -> Path:\n    if not re.fullmatch(r\"[A-Za-z0-9_-]+\", session_id):\n        raise ValueError(\"session id contains invalid characters\")\n    return self.sessions_dir / f\"{session_id}.json\"\n\ndef load_session(self, session_id: str) -> dict[str, Any]:\n    path = self._session_path(session_id)\n    if not path.exists():\n        raise ValueError(f\"No saved session with id '{session_id}'.\")\n    return json.loads(path.read_text(encoding=\"utf-8\"))\n```\n\nAt the start of the loop, we either use the initial system message or we show the messages loaded from a previous session:\n\n```\nmessages = resumed_messages or initial_messages\nif memory_store is not None and session_id is not None:\n    if resumed_messages is None:\n        memory_store.create_session(session_id, messages)\n    configure_memory(memory_store, session_id)\n```\n\n`configure_memory` gives the host-side tools the store and the active session ID. Then we save the messages at three points:\n\nAt each point, the code is the same:\n\n```\nif memory_store is not None and session_id is not None:\n    memory_store.save_messages(session_id, messages)\n```\n\nSince we don't wait for the user to end the session, if the process stops unexpectedly, the last successfully saved messages are still there.\n\nMessages aren't the only state our agent has. The scratchpad and to-do list live in Python memory, and our resource controls track tool calls and cumulative model usage. This means that when we restore a session we also need to restore all of those to avoid having an incomplete session.\n\nOur checkpoint helper saves that state alongside the messages:\n\n``` python\ndef checkpoint():\n    if memory_store is not None and session_id is not None:\n        memory_store.save_messages(session_id, messages)\n        memory_store.update(session_id, state={\n            \"scratchpad\": scratchpad_state._content,\n            \"todos\": todo_store.read(include_completed=True),\n            \"tool_calls\": iteration_caps.tool_calls,\n            **{field: getattr(cost_tracker, field) for field in\n               (\"total_tokens_in\", \"total_tokens_out\", \"total_cost_usd\", \"calls\")},\n        })\n```\n\nWe call this around model requests, after tool batches and after final replies. On a resume command, we restore the scratchpad, all to-do items including their retry counts, the session tool-call count and cumulative API usage. The per-user-turn iteration counter starts again for the next request.\n\nThis means restarting the program doesn't reset the recorded session spend or tool-call count. Limits themselves still come from the current command-line options. While each JSON write is atomic, the messages and state are saved in two separate updates, and a crash between them can leave newer messages with an older planning checkpoint. A crash during a tool or model call can also leave side effects or billing unrecorded.\n\nIf we want to select a previous session to continue, choosing between session IDs is not very helpful. To make finding sessions easier, we will give each session a short summary containing its goal, completed work, important decisions and anything remaining.\n\nThe model can write this summary with a tool:\n\n``` php\ndef write_session_summary(summary: str) -> str:\n    if session_id is None:\n        raise RuntimeError(\"Memory has not been configured.\")\n    return _get_store().write_summary(session_id, summary)\n```\n\nThe inherited `MemoryStore` method replaces the previous summary and updates the timestamp. `SessionStore` wraps this operation in the session lease:\n\n``` php\ndef write_summary(self, session_id: str, summary: str) -> str:\n    session = self.load_session(session_id)\n    session[\"summary\"] = summary.strip()\n    session[\"updated_at\"] = _timestamp()\n    self._write_session(session_id, session)\n    return \"Saved session summary.\"\n```\n\nWe explain when to use it in the system prompt:\n\n```\n\"- Past sessions (list_sessions / search_sessions / read_session): use these to find \"\n\"and inspect prior work. Before your final response on a substantial task, call \"\n\"write_session_summary with the goal, completed work, decisions, and remaining work.\\n\\n\"\n```\n\nWe can't always guarantee that a summary will be created for a session. The agent might forget to call the summary tool, or the session might stop before it gets the chance. As a fallback, the harness calls `ensure_summary`:\n\n``` php\ndef ensure_summary(self, session_id: str) -> None:\n    session = self.load_session(session_id)\n    if session.get(\"summary\", \"\").strip():\n        return\n    excerpts = []\n    for message in session.get(\"messages\", []):\n        if message.get(\"role\") not in {\"user\", \"assistant\"}:\n            continue\n        content = message.get(\"content\")\n        if content:\n            excerpts.append(f\"{message['role']}: {content}\")\n    session[\"summary\"] = \"\\n\".join(excerpts[-6:])[:2000] or \"Session ended without messages.\"\n    session[\"updated_at\"] = _timestamp()\n    self._write_session(session_id, session)\n```\n\nThis takes the last six user/assistant messages with content and keeps up to 2000 characters. It's an excerpt, but at least it gives us something to recognize the session by. And of course, the fallback only runs when the summary is empty.\n\nThe base `MemoryStore` reads the JSON files and returns a small record for each one:\n\n``` php\ndef list_sessions(self) -> list[dict[str, str]]:\n    sessions = []\n    for path in self.sessions_dir.glob(\"*.json\"):\n        try:\n            session = json.loads(path.read_text(encoding=\"utf-8\"))\n            sessions.append({\n                \"id\": session[\"id\"],\n                \"updated_at\": session[\"updated_at\"],\n                \"summary\": session.get(\"summary\", \"\"),\n            })\n        except (OSError, json.JSONDecodeError, KeyError):\n            continue\n    return sorted(sessions, key=lambda item: item[\"updated_at\"], reverse=True)\n```\n\nThe order of the list is by recency, with the most recently updated sessions showing up first. Unreadable or malformed records are skipped and don't show up in the list.\n\nThen, the `SessionStore` extends these records with a title, archive flag and status, filtering archived sessions out by default:\n\n``` python\ndef list_sessions(self, include_archived=False):\n    result = []\n    for item in super().list_sessions():\n        try:\n            record = self.load_session(item[\"id\"])\n        except (ValueError, OSError):\n            continue\n        if include_archived or not record[\"archived\"]:\n            result.append({**item, \"title\": record[\"title\"],\n                           \"archived\": record[\"archived\"],\n                           \"status\": record.get(\"status\", \"saved\")})\n    return result\n```\n\nThe harness marks a running session as `active` and changes it to `saved` or `aborted` during cleanup.\n\nWe expose listing in three places:\n\n`--list-sessions``\\sessions`` list_sessions`\nThe command-line flags use the same memory directory as the previous article:\n\n```\nparser.add_argument(\n    \"--memory-dir\",\n    default=\"~/.agent-harness/memory\",\n    help=\"Directory for durable project/user memory and saved sessions.\",\n)\nparser.add_argument(\n    \"--list-sessions\",\n    action=\"store_true\",\n    help=\"List saved sessions in --memory-dir and exit.\",\n)\nparser.add_argument(\n    \"--resume\",\n    metavar=\"SESSION_ID\",\n    help=\"Resume a saved session by ID.\",\n)\n```\n\nIf we use a custom `--memory-dir`, we need to use that same directory when listing or resuming those sessions.\n\nSometimes we remember the problem we worked on but not the session ID or session title. The solution is the `search_sessions` tool. This tool uses keyword search across the saved records:\n\n``` php\ndef search_sessions(self, query: str) -> list[dict[str, str]]:\n    terms = set(re.findall(r\"\\w+\", query.lower()))\n    matches = []\n    for session in self.list_sessions():\n        record = self.load_session(session[\"id\"])\n        haystack = json.dumps(record, ensure_ascii=False).lower()\n        if all(term in haystack for term in terms):\n            matches.append(session)\n    return matches\n```\n\nThis searches the title, summary, working context and original transcript, using case-insensitive substring matching. Be aware that this solution is not doing any kind of semantic search. Searching for something that might be synonymous to the words that are actually in the session's conversation won't return any result.\n\nFor now, this is enough to find earlier work without adding an embedding model or a vector database. We can improve retrieval later without changing the tool the agent calls.\n\nAfter finding a match, the model can use `read_session`:\n\n``` php\ndef read_session(session_id: str) -> str:\n    session = _get_store().load_session(session_id)\n    transcript = \"\\n\".join(\n        f\"{message.get('role', 'unknown')}: {message.get('content') or '[tool call]'}\"\n        for message in session.get(\"transcript\", session.get(\"messages\", []))\n    )\n    return f\"Summary:\\n{session.get('summary') or '(no summary)'}\\n\\nTranscript:\\n{transcript[:12000]}\"\n```\n\nThis returns the summary and up to 12000 characters of readable transcript. Messages containing only structured tool calls appear as `[tool call]`. Search results show shorter summary previews so the model can select a session before requesting more details.\n\nThese tools run on the host, alongside the other memory tools, and are registered as `PLANNING_TOOLS`. Their results still pass through the tool-result wrapping and output limits we added in the security chapters, so very long sessions may not be fully readable with this tool.\n\nReading a past session brings information of that previous conversation into a new conversation. On the other hand, **resuming** a session restores its saved message history and continues updating the same record.\n\nAt the startup of the agent harness, we load the selected session:\n\n```\nmemory_store = SessionStore(Path(cli_args.memory_dir))\nresumed_messages = None\nif cli_args.resume:\n    try:\n        leases.enter_context(memory_store.lease(cli_args.resume))\n        record = memory_store.load_session(cli_args.resume)\n        if record[\"archived\"]:\n            raise ValueError(\"Session is archived; --unarchive it before resuming.\")\n        expected_root = str(Path(cli_args.sandbox_dir).resolve() if cli_args.sandbox_dir else Path.cwd())\n        if record.get(\"project_root\") and record[\"project_root\"] != expected_root:\n            raise ValueError(\"Session belongs to another project; use its original --sandbox-dir.\")\n        resumed_messages = memory_store.recover(cli_args.resume)\n    except (ValueError, KeyError) as e:\n        print(f\"Could not resume session: {e}\")\n        raise SystemExit(2)\n```\n\nThe active ID is either the requested session ID or a newly generated one:\n\n```\nactive_session_id = cli_args.resume or session_creds.session_id\n```\n\nWe pass that ID and `resumed_messages` into the `agent_loop`. The loop loads the information and proceeds to wait for the next user request.\n\nHere, `leases` is an `ExitStack` kept open until cleanup, so the session stays locked during the run. The project root check prevents continuing a previous session against a different project root.\n\nResuming restores the working messages, including the saved system prompt, the planning and usage checkpoint. But it doesn't restore the Docker container, running commands, credentials, permission approvals or rollback snapshots. The project files are whatever is currently on disk, so the model still needs to inspect them before relying on old findings.\n\nImagine the case that the model used the `write_file` tool and the program stopped before saving its result. The file might have been written, or the call might never have started. We cannot safely assume either outcome.\n\nThere is also a protocol problem: an assistant message containing tool calls needs matching tool results before the conversation continues. Our `recover` method finds calls without results and inserts this message for each missing call ID:\n\n```\nerror = {\n    \"role\": \"tool\",\n    \"tool_call_id\": call_id,\n    \"content\": \"Execution interrupted; outcome unknown. Inspect state before retrying.\",\n}\n```\n\nRecovery repairs the working context and appends the recovery notices to the transcript. The agent harness should not execute the tools again, since automatically replaying an interrupted command could duplicate a change, send a request twice, or overwrite work that already succeeded. The same repair runs after tool batches, which can also stop early because of an abort or a resource limit. It's then up to the agent to inspect the actual state and decide what remains to be done.\n\nTo test the new session features, we start by asking the agent to investigate a problem without making changes:\n\n``` bash\n$ uv run agent-sessions/agent.py\nSession:          z7Pq3hM4kA\nMemory directory: /home/xxxxx/.agent-harness/memory\n\nYou: Investigate the cache bug. Don't make changes yet.\n...\n  [tool] write_session_summary({'summary': 'Investigated the cache bug. The cache key omits the locale. No files changed. Next: include locale in the key and add a regression test.'})\n  [tool result] Saved session summary.\nAssistant: The cache key does not include the locale, so requests in different languages can reuse the same response. I have not changed any files.\nYou: \\exit\n```\n\nWe can see the session summary being generated. Then, we can run the `--list-sessions` CLI argument to find the saved conversation:\n\n``` bash\n$ uv run agent-sessions/agent.py --list-sessions\nz7Pq3hM4kA |  | saved | 2026-10-12T10:24:51+00:00 | Investigated the cache bug. The cache key omits the locale. No files changed. Next: include locale in the key and add a regression test.\n```\n\nThen we start the agent (from the same project directory) and resume the previous session:\n\n``` bash\n$ uv run agent-sessions/agent.py --resume z7Pq3hM4kA --mode acceptEdits\nSession:          z7Pq3hM4kA\nMemory directory: /home/xxxxx/.agent-harness/memory\n\nYou: Fix the issue we investigated and run the tests.\n```\n\nThe model loads the earlier conversation and continues it with this new request. This means we don't have to describe the investigation again, the agent can inspect the current files, review its restored task list and continue the work.\n\nDuring any active conversation, typing `\\sessions` also shows the previous sessions and summary previews. If we want to check a previous work session without resuming it, we can ask the agent to search instead:\n\n```\nYou: Find our previous investigation into the cache bug and remind me what we found.\n  [tool] search_sessions({'query': 'cache bug'})\n  [tool result] z7Pq3hM4kA | 2026-10-12T10:24:51+00:00 | Investigated the cache bug. The cache key omits the locale...\n  [tool] read_session({'session_id': 'z7Pq3hM4kA'})\n  [tool result] Summary:\nInvestigated the cache bug...\n...\n```\n\nOur agent can now keep a conversation between runs and look for earlier work when it is relevant:\n\nWe can close the agent after an investigation and come back to it later, instead of starting the conversation again from scratch.\n\nIn the next part, we will keep a long task going without sending its entire history in every request. We will add **context compaction** to summarize older turns while preserving the original transcript on disk, and finish the session lifecycle with **titles**, **branching**, **archiving**, **exporting** and **deleting** sessions.", "url": "https://wpnews.pro/news/build-a-basic-ai-agent-from-scratch-session-management", "canonical_source": "https://ruxu.dev/articles/ai/build-an-ai-agent-sessions/", "published_at": "2026-10-18 00:00:00+00:00", "updated_at": "2026-10-18 21:50:05.249807+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools"], "entities": ["Build a Basic AI Agent From Scratch", "SessionStore", "MemoryStore", "OpenAI", "GitHub"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/build-a-basic-ai-agent-from-scratch-session-management", "markdown": "https://wpnews.pro/news/build-a-basic-ai-agent-from-scratch-session-management.md", "text": "https://wpnews.pro/news/build-a-basic-ai-agent-from-scratch-session-management.txt", "jsonld": "https://wpnews.pro/news/build-a-basic-ai-agent-from-scratch-session-management.jsonld"}}