agentkit.state.memory¶
The MemorySource Protocol plus the shipped implementations —
CompositeMemory, SequentialMemory, VectorMemory — and the
ScopedMemory / CompactedMemory / CachedMemory decorators.
agentkit.state.memory — the unified memory/RAG seam.
One Protocol (MemorySource), one composite (CompositeMemory),
many backends. An agent declares Agent.memory: MemorySource | None;
the cognition decides when to query and how to fold results into the
working context.
First-party sources and decorators live in this package; concrete adapters land alongside as they're written:
VectorMemory— vector-store backed (the canonical RAG case).FileMemory— file-system backed (a memory tool's read side).JournalMemory— wrapsMutationJournalas a queryable source.ToolMemory— adapts anyToolinto aMemorySource.ScopedMemory— Decorator enforcingctx.scopeat the boundary.CompactedMemory— Decorator summarising results via aCompactor.CachedMemory— Decorator caching hot queries with a TTL.ReadOnlyMemory— Decorator refusing (or dropping) writes, for a source that is read-only by policy rather than by backend.
DedupeMode
module-attribute
¶
How CompositeMemory decides two items are the same fact.
"id" merges items sharing a non-None MemoryItem.id OR identical
content; "content" merges on content alone. None — the third value of
the parameter, not of this alias — is the old pure concatenation.
OnWritePolicy
module-attribute
¶
What a ReadOnlyMemory does with a write. Closed Literal so a
typo is a type error at the call site, not a runtime surprise; the tuple
below re-derives the members with get_args so the runtime check and
the type can never drift apart.
MemoryItem
dataclass
¶
MemoryItem(
content: str,
source: str,
score: float | None = None,
metadata: dict[str, Any] = dict(),
*,
id: str | None = None,
)
The uniform result shape every MemorySource returns.
content is what the agent reads (already-formatted text).
source names the backend that produced it (the MemorySource.name)
so consumers can attribute results and cognitions can format citations.
id is the backend's own identifier for the RECORD — a vector store's
chunk id, a journal row key. Stable within a source; not assumed
comparable across sources unless the caller knows they share a keyspace
(which is the usual case here, because the journal is normally what the
vector store was built from). It exists so CompositeMemory can tell
that two sources returned the SAME fact rather than two facts: without it
the merge was pure concatenation, a reranker scored both copies, and the
top-k the model saw was one fact occupying two slots. None is the
honest answer for a backend that has no stable key (a tool-wrapped search,
a scratchpad), and dedupe falls back to a content digest for those.
score is the backend's relevance signal (cosine similarity for
vector search, recency for journal, etc.); None when the backend
doesn't rank.
metadata carries backend-specific extras (chunk id, file path,
timestamp) that the cognition or the rendering layer may consult. It is
FROZEN at construction (see __post_init__) — still a dict for
every consumer, but a backend that wants to annotate an item builds a new
one: dataclasses.replace(item, metadata={**item.metadata, "path": p}).
id is KEYWORD-ONLY, and that is load-bearing rather than stylistic.
This type is constructed positionally in tens of places
(MemoryItem("c", "vector", 0.91, meta)), so declaring id third —
where it reads most naturally, next to source — would have rebound
0.91 to id and meta to score at every one of them: no
error, wrong data, silently. Appending it after metadata would have
fixed today and left the same trap for the next field; kw_only closes
it permanently and lets the declaration keep the order a reader wants.
MemorySource
¶
Bases: Protocol
A queryable source of information the agent can reach for.
name is the stable diagnostic label — stamped on returned
MemoryItem.source, also used for trace attribution. Implementations
SHOULD set a sensible class-level default.
Implementations MUST honour ctx.scope if the underlying backend
crosses tenants. Multi-tenant enforcement is the implementation's job
OR a ScopedMemory decorator's job; the Protocol doesn't enforce it
itself.
Reranker
¶
Bases: Protocol
Optional second pass over a set of merged MemoryItems.
A composite memory may pull from many sources, each returning items scored on its own scale (vector cosine vs lexical match vs recency). A Reranker scores them on a common axis so the top-k cut is sensible.
The default reranker is score_sort_rerank — sort by score desc
with None last. Real applications wire a cross-encoder or an LLM
judge here.
CompositeMemory
dataclass
¶
CompositeMemory(
sources: list[MemorySource],
reranker: Reranker | None = None,
name: str = "composite",
dedupe: DedupeMode | None = "id",
)
Parallel fan-out across many sources.
Each query runs against every source concurrently. Results are
merged, then optionally reranked. Top-k is taken from the reranked
list. The default reranker is score_sort_rerank (sort by score
desc); pass a cross-encoder or LLM judge for richer semantics.
Backpressure: asyncio.gather is bounded by Python's default
semaphore behaviour — for very wide trees, prefer nesting
CompositeMemory(CompositeMemory(...), ...) to keep fanout
moderate at each level.
dedupe decides what happens when two sources hold the SAME fact,
which in this composition is the normal case rather than an accident:
ask a vector store and a journal the same question and they overlap,
because the journal is usually what the store was built from. The merge
used to be [item for batch in batches for item in batch] — pure
concatenation — so a reranker scored both copies and the top-k the model
saw was one fact occupying two slots. That is a WRONG answer, not a
missing feature, which is why "id" is the DEFAULT and None is the
opt-out a caller chooses rather than inherits:
"id"— merge on a sharedMemoryItem.idOR identical content. A union rather than "id, else content" because a per-item fallback would mean that populatingidon a backend stops it matching backends that have none — the headline case would regress the momentVectorMemorystarted reporting its chunk ids. See_merge_duplicates."content"— content only, for stores whose ids are not comparable (two stores can both call their first row"1", and trusting that merges two unrelated facts, which deletes one). The single behavioural difference from"id": a shared id with different text.None— the old concatenation, kept for callers who genuinely want every copy (a diversity reranker, a provenance audit).
A survivor of an actual collision is stamped with dedupe_sources (the
distinct source names that produced a copy) and dedupe_count. Items
that never collided come back exactly as their backend returned them.
Dedupe runs BEFORE rerank, which is the point of it: a cross-encoder scoring the same passage twice pays double and then still spends two top-k slots on it. Cost is one sha256 per id-less item (~1 µs for a 1 KB passage) against the network round trips that produced the pool.
accepts_writes
property
¶
True when at least one member can actually commit.
A composite is itself a MemorySource and composites nest, so
without this the marker dies at the first composite boundary: an
all-read-only CompositeMemory nested in an outer fan-out
returns normally from write having stored nothing, and the
outer split files it under accepted — the exact lie the
refused bucket exists to prevent, one level up.
any rather than all because the bucket asks "did anything
land here?", and a composite with one writable member did commit.
An empty composite commits nothing, and any(()) is False,
which is the honest answer.
write
async
¶
write(items: Iterable[MemoryItem], *, ctx: Ctx) -> None
Broadcast writes to every source. A backend that can't accept
writes implements write as a no-op (see ToolMemory) and
sets accepts_writes = False so the split can tell it apart
from a backend that committed.
Partial failures surface as :class:CompositeWriteError with a
per-source accepted/refused/failed split. Without
return_exceptions=True the first child exception would
propagate and the rest would be discarded, so a caller seeing a
failure would have no way to know which backends had already
committed.
CompositeWriteError
¶
CompositeWriteError(
*,
accepted: list[str],
failed: dict[str, BaseException],
refused: list[str] | None = None,
)
Bases: RuntimeError
At least one source in a CompositeMemory.write fan-out failed.
Carries a structured per-source split so callers can compensate (retry the failed sources, or surface the partial-commit state to the operator). Raising the first child exception verbatim would discard everything else and postmortems would lose the information about which backends DID commit.
refused is a THIRD bucket, not a slice of accepted. A source
that declares accepts_writes = False (ReadOnlyMemory with
on_write="ignore") returns from write without raising and
without storing anything, so the two-bucket split had exactly one
place to put it and that place was a lie: accepted is what an
operator reads to decide which backends NOT to replay after a partial
commit, and replaying is skipped for a source that never committed.
Keyword-only with a default so the constructor stays
backward-compatible for callers that build the error themselves.
Attributes:
| Name | Type | Description |
|---|---|---|
accepted |
list of source names whose |
|
refused |
list of source names that declared themselves read-only
and dropped the write. Not an error — the fan-out still
succeeds when nothing failed — but never conflated with
|
|
failed |
dict mapping source name → BaseException raised by that source. |
SequentialMemory
dataclass
¶
SequentialMemory(
sources: list[MemorySource], name: str = "sequential"
)
Try sources in order; stop when k items are collected.
Use for cache-then-fallback patterns: a fast in-memory source first, an expensive vector source second. The second source isn't hit if the first answered. Items returned in the order sources were declared (preserves the "cache hit beats fresh fetch" intent).
NO dedupe here, deliberately, and it is not because the exposure is
absent: a cache that returns 3 of 5 wanted items is followed by a vector
lookup that can return copies of those same 3, so duplicates reach the
caller exactly as they did in CompositeMemory. The difference is that
the fix cannot be a merge pass. This class asks each source for
k - len(collected) items, so collapsing a duplicate afterwards leaves a
short list with nothing left to backfill from — the source that could have
supplied the replacement has already been asked and answered. Doing it
honestly means re-querying with a widened k (or over-fetching every
tier by the expected overlap), which changes the round-trip count and the
cost profile of the shape whose entire purpose is not making the second
call. That is a design decision with a latency budget attached, not a
merge, so it is left as its own item rather than smuggled in here. Wrap
the chain in CompositeMemory(sources=[chain], dedupe="id") for a
single-arm dedupe in the meantime.
accepts_writes
property
¶
Mirrors the FIRST source, because that is the only one
write ever touches — downstream sources are read-only from
this composite's point of view. A SequentialMemory whose
cache tier is a ReadOnlyMemory stores nothing on write, and
an enclosing fan-out must not report that as a commit. No
sources means no write target, hence False.
write
async
¶
write(items: Iterable[MemoryItem], *, ctx: Ctx) -> None
Write to the FIRST source only — typical cache-tier pattern. Downstream sources are read-only from this composite's POV.
CachedMemory
dataclass
¶
CachedMemory(
inner: MemorySource,
ttl_seconds: float = 60.0,
max_entries: int = 256,
name: str = "",
strict_scope: bool = False,
)
Decorator: cache query → results with a TTL.
The cache key is (query, k, frozenset(where or {})). Hits
served without touching the inner source. Writes invalidate the
cache (best-effort — a write may surface stale results to an
in-flight query, which is the typical "read-after-write
eventual consistency" trade-off).
ttl_seconds is wall-clock monotonic — the cache never serves
an entry older than this. max_entries caps the cache size;
eviction is LRU-ish (oldest insertion drops when full).
Not thread-safe; agents are single-flow per loop.
accepts_writes
property
¶
The cache never stores anything durably itself — it forwards writes — so whether this stack accepts writes is the inner source's answer.
CompactedMemory
dataclass
¶
CompactedMemory(
inner: MemorySource,
compactor: Any,
max_items: int | None = None,
name: str = "",
)
Decorator: shrink each MemoryItem.content via a Compactor.
Used when raw chunks would blow the prefix budget. Adapts the
framework's Compactor capability (which operates on
list[Message]) to operate on individual items: each item's
content is wrapped as a single message, compacted, and the result
becomes the new content.
max_items caps the post-compaction list — useful when the
inner source returns more items than the cognition wants to fold
into the prompt.
accepts_writes
property
¶
Compaction is a read-side concern; write is a straight
pass-through, so the marker is the wrapped source's.
MemoryWriteRefused
¶
Bases: AgentkitError
A ReadOnlyMemory turned away a write.
Subclasses AgentkitError rather than ValueError or a bare
RuntimeError because kernel/errors.py makes one promise about
this surface — "every framework-raised exception is a subclass of
AgentkitError so a defensive except AgentkitError: catches
the whole surface" — and a run boundary that catches the taxonomy
should not need a second clause for this one.
Named rather than anonymous for the reason CompositeWriteError
is: it lands inside CompositeWriteError.failed when a read-only
member sits in a fan-out under the default policy, and an operator
reading that split needs to tell "the registry is deliberately
read-only" apart from "the registry is down". PermissionError
was the other candidate and was rejected — ScopedMemory already
raises it for a tenant-boundary violation, and collapsing "wrong
tenant" into "right tenant, immutable source" would make the two
indistinguishable at the catch site.
Attributes:
| Name | Type | Description |
|---|---|---|
source |
the wrapped source's |
|
n |
how many items the refused call carried. |
ReadOnlyMemory
dataclass
¶
ReadOnlyMemory(
inner: MemorySource,
on_write: OnWritePolicy = "refuse",
name: str = "",
)
Decorator: a source that is read-only by policy says so.
Every application has at least one source that must never be written
— a curated knowledge base, an operator-maintained registry, a corpus
of recorded facts the agent may consult and must not extend. Before
this decorator the only protection was that nothing happened to call
write, which is a property of the code as it currently stands
rather than a rule: the first cognition taught to persist what it
learned would have written into the registry and nothing would have
complained. Wrapping the source turns the convention into an
enforced boundary that survives the next refactor.
Single-responsibility like its neighbours: it constrains ONE verb.
query is a byte-for-byte pass-through — same k, same
where, same items back with their source stamp intact, which
matters because that stamp is the identity a composite's merge and
dedupe key on. A read-only member participates in recall exactly like
any other source; that is the whole point of having it.
Two policies, because a fan-out forces the distinction:
on_write="refuse"(default) — raiseMemoryWriteRefused. The right answer for a source nothing has any business writing to; the exception names the source so a stack trace points at the policy rather than at the plumbing.on_write="ignore"— return normally, having written nothing.CompositeMemory.writebroadcasts to every source, and one read-only member must not make the whole composite unwritable.
ignore is the dangerous one, because "silently succeeds" is the
failure mode this package keeps writing tests against. It is
accounted for three ways rather than none: refused_writes counts
the turned-away calls on the instance, a memory.write_refused
observation lands on the run's timeline with the source and the item
count, and accepts_writes = False lets CompositeMemory file
the member under a refused bucket instead of claiming in
CompositeWriteError.accepted that it committed.
Nests either way round. ReadOnlyMemory(ScopedMemory(x)) refuses
before the tenant guard runs (cheaper, and no PermissionError
masking the real reason); ScopedMemory(ReadOnlyMemory(x)) checks
the tenant first. Double wrapping is harmless — the outer refuses and
the inner never runs.
name mirrors the wrapped source, like every other decorator here,
so the guard is invisible in attribution.
query
async
¶
query(
query: str,
*,
k: int,
ctx: Ctx,
where: dict[str, Any] | None = None,
) -> list[MemoryItem]
Untouched. No filtering, no re-stamping, no defensive copy —
this decorator has an opinion about write and none at all
about recall.
write
async
¶
write(items: Iterable[MemoryItem], *, ctx: Ctx) -> None
Refuse or drop, per on_write. self.inner.write is never
awaited on either path — that is what makes "read-only" a fact
about the wrapper rather than a hope about the backend, and it is
why a wrapped source that raises on write is unreachable here.
items is materialised before the branch so the refusal can
report n. It is the only reason to touch the iterable at all,
and it costs one list build on a path that is about to raise or
drop.
An EMPTY write still refuses. The policy is about the attempt,
not about bytes moved: a caller that reaches write on a
read-only source is running code that will carry items the moment
its input is non-empty, and letting write([]) through means
the violation is found in production instead of on the first test
run. Nothing in the package pays for this — both
CompositeMemory.write and SequentialMemory.write
short-circuit on an empty list before reaching any source.
ScopedMemory
dataclass
¶
ScopedMemory(
inner: MemorySource,
enforce: Callable[[Ctx], Any] | None = None,
name: str = "",
)
Fail-loud multi-tenant guard around any MemorySource.
Every query and every write runs enforce(ctx) first.
If enforce is None, the default check fires — both
ctx.scope.org_id and ctx.scope.domain_id must be set.
A custom enforce callable lets callers pin a stricter policy
(e.g. "must match this specific tenant", "must be inside an
approved checkpoint"). Returning a falsy value OR raising any
exception inside enforce results in a PermissionError — the
inner source is NOT touched.
The decorator's name mirrors the wrapped source so downstream
consumers attributing MemoryItem.source see the same label
they'd see without the wrapper — the guard is invisible on the
happy path.
accepts_writes
property
¶
Mirror the wrapped source. A tenant guard around a read-only
source is still read-only, and a composite that read True
here would file the member under accepted.
FileMemory
dataclass
¶
Lexical MemorySource over a file-tree backend.
files is duck-typed: any object exposing async view(path),
create(path, text), delete(path). The default is the
in-tree InMemoryFiles; a durable / FS-backed implementation
that conforms to the same surface drops in without changes.
query(query, k, ctx, where=None) — walks every file, scores
by lowercase-substring match count, returns the top k items
with metadata {"path": …}. Zero-match files are dropped.
where={"path_prefix": "/foo"} scopes the walk to that prefix.
write(items, ctx) — writes each item via create. The
target path comes from item.metadata["path"] if present,
otherwise from a SHA-1 hash of the content.
JournalMemory
dataclass
¶
JournalMemory(
journal: MutationJournal[T],
render: Callable[[T], str],
name: str = "journal",
)
Bases: Generic[T]
A MemorySource view on a MutationJournal[T].
render turns each journal entry into the text the cognition
will read — pushed to the caller so the journal's entry type
stays unconstrained.
Order semantics: query returns the most recent k
entries (no relevance ranking — journals are temporal). Score is
always None on returned items.
Filters
query — lexical filter; case-insensitive substring of the
rendered text. Empty string disables the filter.
where={"role": "..."} — keep only entries whose role
attribute matches. Entries without a role attribute
are skipped under this filter.
write
async
¶
write(items: Iterable[MemoryItem], *, ctx: Ctx) -> None
No-op — the journal's authored history is owned by
journal.record(); a memory write seam would break that
invariant.
ScratchpadMemory
dataclass
¶
ScratchpadMemory(
context: WorkingContext, name: str = "scratchpad"
)
A MemorySource view on a WorkingContext.scratchpad.
Keys are inspected as-is; values are rendered via str(value)
for matching. Returned items carry metadata={"key": key} so
a downstream writer or reader can address the entry by name.
ToolMemory
dataclass
¶
ToolMemory(
tool: Any,
query_arg: str = "query",
result_to_items: Callable[[Any, str], list[MemoryItem]]
| None = None,
name: str = "tool",
)
Adapter wrapping a FunctionTool (or any Tool-Protocol object)
as a MemorySource. The tool becomes a knowledge probe the
cognition can query alongside vector/file/journal sources.
The tool is invoked with a dict of args built from
{query_arg: query, "limit": k, **(where or {})}. where keys
are passed through unmodified so a tool with a typed parameter like
site: can use them directly.
Parsing the tool's return into MemoryItems defaults to
:func:default_parse and can be overridden per-instance with
result_to_items=. Every emitted item has its source stamped
with this instance's name so downstream consumers can attribute
correctly.
Writes are no-ops. A wrapped tool is read-only as memory; the same
underlying callable can be registered as a FunctionTool for
LLM-decided side effects without conflict.
write
async
¶
write(items: Iterable[MemoryItem], *, ctx: Ctx) -> None
No-op. Tools are query-only as memory; a side-effecting tool
belongs in the ToolRegistry, not as a memory write sink.
VectorMemory
dataclass
¶
A MemorySource backed by a VectorPort.
where is the default metadata filter applied to every query
and (informationally) attached at write time only if the caller
embeds it in MemoryItem.metadata. At query time, a call-time
where= merges over the constructor default — call-time wins
on key collisions, so a wired-in narrow filter can be widened or
overridden per-query without rebuilding the source.
Tenant isolation is delegated to the underlying VectorPort.upsert
/ VectorPort.search, which both take ctx.scope as their
bucket key. Wrap with ScopedMemory for fail-loud enforcement
at the framework boundary.
query
async
¶
Top-k scope-restricted lookup. The call-time where
merges over the constructor default (call-time wins). The
underlying VectorPort.search returns (score, Chunk);
we adapt each pair to a MemoryItem and stamp source
with this instance's name so downstream consumers can
attribute the result.
Chunk.id rides along on MemoryItem.id. It used to be the one
field the adapter dropped, which was an odd asymmetry — write has
always keyed chunks by it, so the store knew the identity of every row
it handed back and the item did not. Two consequences followed:
CompositeMemory could not tell that the journal and the vector
store had returned the same fact (the normal case, since the journal is
usually what the store was built from), and writing a recalled item
back inserted a SECOND copy under a fresh uuid instead of updating the
row.
write
async
¶
Index a batch of MemoryItems. Each item becomes a
Chunk keyed by item.id, else metadata["id"] when the
caller wrote one, else a fresh uuid. content becomes
Chunk.text; the rest of metadata is preserved so callers
can later where=-filter on whatever tags they wrote.
item.id takes precedence because read-modify-write is the loop
this closes: an item recalled from this store carries its row's id, so
writing it back must UPDATE that row. Under the uuid fallback it
inserted a second copy of the same fact instead, which then had to be
cleaned up downstream by CompositeMemory's dedupe — a duplicate
manufactured by the store rather than found in it.
It is trusted ONLY when the item came from this instance, though, and
that guard is load-bearing rather than defensive. item.id is a
PROVENANCE record — the key of the row a backend handed back — and ids
are unique only within a backend. CompositeMemory.write broadcasts
every item to every source, so without the guard an item recalled from
a journal whose row key happened to be "3" upserted over vector
chunk "3": an unrelated fact silently destroyed, on the default
write path. query stamps source with this instance's name,
so the round trip above still matches and still updates in place.
metadata["id"] stays supported underneath, and outranks the missing
provenance match, because it is the caller INSTRUCTING this store which
row to write rather than reporting where an item came from. It is
popped either way so the key never survives into Chunk.metadata —
two places claiming to be the id would make the next round trip depend
on which one won.
score_sort_rerank
async
¶
score_sort_rerank(
query: str, items: list[MemoryItem], *, k: int
) -> list[MemoryItem]
Default: stable sort by score descending. None scores sink to
the bottom but aren't dropped — a tool-wrapped source that doesn't
rank still gets to surface in the top-k.
as_grounder
¶
as_grounder(
memory: MemorySource,
*,
k: int = 5,
where: dict[str, Any] | None = None,
format: Callable[[list[MemoryItem]], str] | None = None,
) -> Callable[[Ctx, str], Awaitable[str]]
Adapt a MemorySource into a RequestBuilder.Grounder.
k and where are baked into the returned closure. format
overrides the default [source] content rendering — pass a
custom formatter when the prompt expects a specific shape (Markdown
bullets, numbered citations, JSON, etc.).
The query passed to the grounder is the user task — the same string the agent is about to answer. For "query rewriting" before retrieval (HyDE, query decomposition) wrap the memory itself rather than transforming inside the grounder.
as_grounding_source
¶
as_grounding_source(
memory: MemorySource,
*,
k: int = 5,
where: dict[str, Any] | None = None,
) -> Callable[[Ctx, str], Awaitable[Sequence[MemoryItem]]]
Adapt a MemorySource into a RequestBuilder.GroundingSource.
The same closure as_grounder builds, minus the flattening step — which
is the whole implementation and also the whole point. There is no
format= here because rendering has moved to where it can see what it
is rendering: RequestBuilder(render=...), downstream of
RequestBuilder(admit=...). Keeping a formatter on this side too would
give two places to decide the same thing, and the one on this side runs
BEFORE admission, so a rule like "drop model-authored memories" would be
applied to text that had already been joined.
k and where are baked in at wiring time exactly as before, and the
query is still the user task verbatim — for query rewriting (HyDE,
decomposition) wrap the MemorySource rather than reaching inside this
closure.
Note the return annotation is the structural Callable[...] rather than
GroundingSource: agentkit.state.memory must not import
agentkit.capabilities (the capabilities side imports MemoryItem
from here, and the pair would cycle). as_grounder above spells its
return type out for the same reason.
default_parse
¶
default_parse(result: Any, query: str) -> list[MemoryItem]
Best-effort parser for tool-call return values.
Handles the four shapes a search-ish tool commonly returns:
dictwith a"results"key → recurse on theresultslistlist[dict]→ each dict is treated as{content, score?, metadata?}(anything else in the dict becomes metadata;"text"and"snippet"are accepted as content aliases for convenience)list[str]→ each string becomes aMemoryItem.contentstr→ split on blank lines ("\n\n") into multiple items; a single-paragraph string yields one item
Anything else is wrapped as a single item with content=str(result) —
enough to be visible in traces, not enough to be useful: callers with
richer return shapes should pass result_to_items=.
The query argument is accepted for parity with custom parsers
(which may want to highlight matches) but the default parser ignores it.
render_grounding
¶
render_grounding(items: Sequence[MemoryItem]) -> str
Render records as attributed evidence; callers decide admission first.