Skip to content

Add BaseChatHistory: a pluggable memory base class (closes #262)#263

Open
Eigenwise wants to merge 4 commits into
mainfrom
feat/pluggable-memory-base-class
Open

Add BaseChatHistory: a pluggable memory base class (closes #262)#263
Eigenwise wants to merge 4 commits into
mainfrom
feat/pluggable-memory-base-class

Conversation

@Eigenwise

Copy link
Copy Markdown
Owner

Closes #262.

This is the follow-up to that Dakera memory-adapter request. We are still not bundling adapters for every memory service out there, that road ends in bloatware. But the ask underneath it was fair: right now if you want your own persistent memory, you have to subclass ChatHistory and reverse-engineer which methods the agent actually calls. There was no declared contract, so it was easy to get wrong and easy for us to break.

So this gives people a clean, documented seam without adding a single runtime dependency.

What's in it

  • BaseChatHistory — a new interface-only ABC in atomic_agents.context that declares exactly what AtomicAgent relies on from a history object: add_message, get_history, initialize_turn, get_current_turn_id, delete_turn_id, get_message_count, dump, load, copy, plus the history / current_turn_id attributes the trim logic reads. Every method is documented with its contract.
  • ChatHistory now extends it. Behavior is unchanged, this is fully backward compatible. Existing code keeps working with zero changes.
  • AgentConfig.history is typed to BaseChatHistory, so anything implementing the contract drops straight in.
  • Docs: a new "Writing a Custom Memory Backend" section in the memory guide (recommended subclass-ChatHistory pattern + the from-scratch route), and the base class added to the API reference.
  • A runnable example: atomic-examples/persistent-memory, a SQLiteChatHistory that gives real cross-session recall (the thing the original requester actually wanted) using only stdlib sqlite3, so no new deps. Run it twice and the second run remembers the first.

The pattern

from atomic_agents.context import ChatHistory

class SQLiteChatHistory(ChatHistory):
    def __init__(self, session_id, db_path="chat_history.db", max_messages=None):
        super().__init__(max_messages=max_messages)
        # ... open db, rehydrate via self.load(...) if a row exists ...

    def add_message(self, role, content):
        super().add_message(role, content)
        self._save()   # persist self.dump()

Your memory adapter lives in your codebase. Core stays lightweight and provider-agnostic.

Verification

  • black --check clean (199 files)
  • flake8 clean
  • pytest: 325 passed, 3 skipped (the 3 are pre-existing integration skips), 93% coverage. New test_base_chat_history.py covers: base can't be instantiated, ChatHistory is a subclass/instance, an incomplete subclass raises TypeError, and a custom subclass works end-to-end through an agent run.

🤖 Generated with Claude Code

Eigenwise and others added 4 commits July 5, 2026 19:33
Memory stays out of the framework core on purpose, but until now plugging in
your own persistent backend meant subclassing ChatHistory and guessing which
methods the agent actually calls. This gives people a clear, stable seam.

- BaseChatHistory: an interface-only ABC in atomic_agents.context that declares
  the memory contract AtomicAgent relies on (add_message, get_history, turn
  handling, dump/load, copy) plus the history/current_turn_id attributes.
- ChatHistory now extends BaseChatHistory (behavior unchanged, fully backward
  compatible), and AgentConfig.history is typed to the base so any conforming
  backend drops in.
- Docs: a "Writing a Custom Memory Backend" guide section + API reference.
- Example: atomic-examples/persistent-memory, a dependency-free SQLiteChatHistory
  giving cross-session recall using only the stdlib.

Closes #262

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The TYPE_CHECKING-guarded 'import Message' never executes at runtime by
design, so it was the sole line keeping base_chat_history.py at 88%.
Excluding 'if TYPE_CHECKING:' is the canonical, correct coverage exclusion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The blind review caught a real footgun: ChatHistory.copy() hard-codes
ChatHistory(), and AtomicAgent uses copy() for initial_history and
reset_history(). A custom backend (e.g. SQLiteChatHistory) would silently
degrade to a non-persistent in-memory history after reset_history() — and the
documented "recommended pattern" walked users straight into it.

- SQLite example now overrides copy() (preserves type + persistence) and opens
  a short-lived connection per operation via contextlib.closing (no leaked
  handle, thread-safe), instead of holding one connection for its lifetime.
- Memory guide: the recommended pattern now includes a copy() override, an
  {important} callout on the reset_history() footgun, and a {note} that the
  full-history re-dump is O(n^2) and production backends should persist
  incrementally.
- BaseChatHistory.copy() docstring warns that stateful subclasses must override it.
- Reframed the ABC docstring: it declares ChatHistory's full public contract,
  of which AtomicAgent uses a subset (not "the contract AtomicAgent depends on").
- AtomicAgent: `config.history if config.history is not None else ChatHistory()`
  so a falsy custom backend (one defining __len__) is not silently discarded.
- Tests: a from-scratch BaseChatHistory driven through agent.run; a
  reset_history() regression pinning that an overridden copy() preserves the
  backend; the un-overridden downgrade behavior; and the falsy-history guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Eigenwise

Copy link
Copy Markdown
Owner Author

Ran an independent blind review of this branch. One finding was worth blocking on; addressed in 5bf82b3.

HIGH — copy() silently downgraded a custom backend (fixed). ChatHistory.copy() hard-codes ChatHistory(...), and AtomicAgent uses copy() for initial_history and reset_history(). So a SQLiteChatHistory would silently become a non-persistent in-memory history after reset_history() — and the documented "recommended pattern" led users right into it.

  • The SQLite example now overrides copy() (preserves type + persistence) and opens a short-lived connection per write via contextlib.closing (no leaked handle, thread-safe).
  • The memory guide's recommended pattern now shows a copy() override, with an {important} callout on the reset_history() footgun and a {note} that re-dumping the whole history per message is O(n²) (production backends should persist incrementally).
  • BaseChatHistory.copy() docstring warns stateful subclasses must override it.

MEDIUM — falsy backend discarded (fixed). config.history or ChatHistory() would drop a custom backend that defines __len__ and is empty at construction. Now ... if config.history is not None else ....

MEDIUM — ABC framing (fixed). Reframed the docstring: it declares ChatHistory's full public contract, of which AtomicAgent uses a subset — not literally "the contract AtomicAgent depends on."

Tests added: a from-scratch BaseChatHistory driven through agent.run; a reset_history() regression pinning that an overridden copy() preserves the backend; the un-overridden downgrade behavior; and the falsy-history guard.

Gate after fixes: black + flake8 clean, 329 passed / 3 skipped, base_chat_history.py at 100%.

Lower-severity notes not actioned (by design): the ABC can't enforce the history/current_turn_id attribute contract (Python limitation, documented); example pyproject carries the same multi-provider instructor pin as the other examples (kept for workspace consistency).

@Eigenwise

Copy link
Copy Markdown
Owner Author

Actually ran the examples (not just static checks). No API keys here, so I drove each real example script through runpy with the LLM boundary mocked (no network) and stdin scripted — this executes the true code path my change touches: AgentConfig.history validation, copy() for initial_history, and the run / run_stream / run_async / run_async_stream loops through the history.

Ran to completion:

  • persistent-memory — run twice against a temp db: run 1 saved 5 messages, run 2 restored 5. Cross-session recall works through the actual main() loop, and the copy() fix holds (a fresh smoke test confirmed copy() returns a SQLiteChatHistory, not a plain ChatHistory, and keeps persisting).
  • quickstart 1_0, 1_1 (stream), 1_2 (async stream), 2 (custom prompt), 3_0 + 3_1 (custom schema, incl. streaming), 5 (reasoning system role), 6_0 + 6_1 (async). All pass.
  • hooks-example — passes (the hook-registration path against a custom-typed history). One caveat: it throws a Windows cp1252 UnicodeEncodeError on an emoji print unless stdout is UTF-8; that's pre-existing/environmental, unrelated to this change.

Not run (need live external services / assets / real keys, and don't exercise the history seam any differently): web-search, rag-chatbot, deep-research, mcp-agent, progressive-disclosure, the multimodal/pdf/youtube examples, dspy-integration, fastapi-memory, orchestration-agent.

Backward compatibility confirmed at runtime, including the streaming and async paths.

@Eigenwise

Copy link
Copy Markdown
Owner Author

Update: verified against a live LLM provider too (not just mocked). OpenAI was out of quota (429 insufficient_quota), so I ran against Groq (llama-3.3-70b-versatile, JSON mode):

  • Custom backend, real cross-session recall — using the example's actual SQLiteChatHistory: instance A told the model "favorite color is teal, dog named Pixel" (persisted 2 messages); a brand-new instance B over the same db rehydrated them and the live model answered "Your favorite color is teal, and your dog's name is Pixel." The copy() fix held (initial_history stayed a SQLiteChatHistory).
  • Stock ChatHistory, in-session memory — turn 1 "my name is Kenny", turn 2 "what is my name?" → "Your name is Kenny." (initial_history is a plain ChatHistory, default path unchanged).

Worth noting: even the OpenAI attempt proved the persistence layer — a fresh process restored the saved messages from SQLite before the completion 429'd; only the LLM call failed, on billing, not on our code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make agent memory pluggable via a clear base class (BaseChatHistory) + docs + example

1 participant