Memory adapters¶
Three stores, each good at one thing, behind the one MemoryStore a consumer binds.
| Kind | Store | Why that one |
|---|---|---|
| working | RedisMemoryStore |
Expiry is the server's job, not a sweeper's |
| profile, episodic | PostgresMemoryStore |
A history nobody may overwrite |
| semantic | PgvectorMemoryStore |
A ranking belongs in the query planner |
settings = MemoryStoreSettings(dsn=SecretStr(os.environ["ADK_POSTGRES_DSN"]))
store = RoutedMemoryStore(
working=RedisMemoryStore(redis, clock=clock, settings=settings, ttl_seconds=3600),
durable=PostgresMemoryStore(sql, clock=clock, settings=settings),
semantic=PgvectorMemoryStore(sql, clock=clock, settings=settings, dimensions=1024),
)
Each store declares what it can do. A plan that needs ranking cannot be bound to a
key-value store and find out a month later, because require_memory checks first.
Credentials¶
MemoryStoreSettings is the only place a connection string comes from. It is never read
from a row, and nothing here has a value that works out of the box:
MemoryStoreSettings(dsn=SecretStr("")) # ValueError
MemoryStoreSettings(dsn=SecretStr("postgres://postgres:postgres@db/adk")) # ValueError
The DSN is a SecretStr, so a settings object in a log or a traceback carries
SecretStr('**********') and not the password.
Working memory¶
The key carries the whole scope — adk:mem:<tenant>:<user>:<session>:<agent>:working:<key> —
so two sessions under one tenant are two scratch spaces. sliding=True extends a key on
read, for a conversation that is still in progress rather than idle.
append is one server-side script. A client-side read-modify-write is two chances to lose
the other append, and the position it returns is what lets a caller notice that eviction
under maxmemory took the sequence: an append that says it is the first is a lost one. A
key that is gone reads as None, because absent is what it is.
Profiles and episodes¶
Nothing is overwritten. supersede closes the old version in the UPDATE's own predicate,
so two writers reading version 1 cannot both succeed — the loser gets MemoryConflictError
with the version that is actually live. resolves= closes named live records in the same
write, so a branch ends because somebody decided rather than because a read picked a side.
log inserts ON CONFLICT (id) DO NOTHING. A primary that fails over mid-append is
retried, and the retry commits the episode once rather than booking it twice.
A window wide enough to matter is wider than one response:
page = await store.page(scope, MemoryQuery(kind=MemoryKind.EPISODIC, limit=200))
while page.cursor:
page = await store.page(scope, query, cursor=page.cursor)
Keyset, not OFFSET: an offset re-reads every row before it in order to skip them.
Semantic recall¶
The scope filter is part of the SQL predicate. A filter applied after the fetch has already read the rows it was supposed to exclude, which for a tenant boundary is the whole point.
metric picks the operator — cosine <=>, l2 <->, inner <#> — and must match
what the index was built for. Scores are 1 - distance, comparable within one result set
and not across two.
A collection two dimensions narrower than the embedder does not fail. It ranks badly, on
the first recall, a month later, with nobody watching. verify() turns that into an
EmbeddingDimensionError while somebody is still deploying.
When the store is unreachable¶
A failover is ordinary and is waited out — bounded attempts, doubling with jitter. A store
still gone once the budget is spent raises MemoryUnavailableError and the run fails
closed, because an agent that silently remembers nothing looks exactly like one whose user
said nothing.
An exhausted connection pool is different and is reported at once as PoolExhaustedError.
The endpoint is fine and the process is over-subscribed; retrying into a full pool under a
tool fan-out is how a spike becomes an outage.
Not here¶
Schema DDL. Tables, indexes and the vector extension belong to the platform's
migration repo. Importing a library must never be the thing that alters a production table.
Erasure narrowing. RoutedMemoryStore.erase refuses kinds= and dry_run=: three
servers with no shared transaction cannot promise either honestly. Use the individual
stores, or InMemoryMemoryStore where the two-phase protocol of docs/erasure.md applies.
The knowledge-graph adapter. A different story.
Verifying against real servers¶
The unit tests check what the adapters send. Whether it is valid Lua and valid SQL is something no fake can tell you, so the conformance suite also runs against containers:
ADK_TEST_REDIS_URL=redis://localhost:6379/9 \
ADK_TEST_POSTGRES_DSN=postgresql://adk:...@localhost/adk_test \
uv run pytest tests/integration -m integration
The default lane excludes them and reaches no network.