Add BaseChatHistory: a pluggable memory base class (closes #262)#263
Add BaseChatHistory: a pluggable memory base class (closes #262)#263Eigenwise wants to merge 4 commits into
Conversation
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>
|
Ran an independent blind review of this branch. One finding was worth blocking on; addressed in HIGH —
MEDIUM — falsy backend discarded (fixed). MEDIUM — ABC framing (fixed). Reframed the docstring: it declares Tests added: a from-scratch Gate after fixes: black + flake8 clean, 329 passed / 3 skipped, Lower-severity notes not actioned (by design): the ABC can't enforce the |
|
Actually ran the examples (not just static checks). No API keys here, so I drove each real example script through Ran to completion:
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. |
|
Update: verified against a live LLM provider too (not just mocked). OpenAI was out of quota (429
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. |
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
ChatHistoryand 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 inatomic_agents.contextthat declares exactly whatAtomicAgentrelies 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 thehistory/current_turn_idattributes the trim logic reads. Every method is documented with its contract.ChatHistorynow extends it. Behavior is unchanged, this is fully backward compatible. Existing code keeps working with zero changes.AgentConfig.historyis typed toBaseChatHistory, so anything implementing the contract drops straight in.ChatHistorypattern + the from-scratch route), and the base class added to the API reference.atomic-examples/persistent-memory, aSQLiteChatHistorythat gives real cross-session recall (the thing the original requester actually wanted) using only stdlibsqlite3, so no new deps. Run it twice and the second run remembers the first.The pattern
Your memory adapter lives in your codebase. Core stays lightweight and provider-agnostic.
Verification
black --checkclean (199 files)flake8cleanpytest: 325 passed, 3 skipped (the 3 are pre-existing integration skips), 93% coverage. Newtest_base_chat_history.pycovers: base can't be instantiated,ChatHistoryis a subclass/instance, an incomplete subclass raisesTypeError, and a custom subclass works end-to-end through an agent run.🤖 Generated with Claude Code