Memory

The model writes a note under a key and reads it back on a later run. The harness stores that text, shows it back, and never treats it as instruction.

Where it lives

internal/memory holds the contract: the Store interface, key rules, the write validator and the on-disk format. internal/memory/file and internal/memory/jetstream are the two backends. internal/toolkit/builtin/builtin_memory.go is the tool surface the model sees. Key files: store.go, key.go, write.go, frontmatter.go, scope.go.

The contract

Store has six methods and no Close. No backend owns a resource to release; the JetStream connection is borrowed from the host and must never be closed by the backend.

type Store interface {
	Info() Info
	List(ctx context.Context) ([]Item, error)
	Read(ctx context.Context, key string) (description, content string, err error)
	Create(ctx context.Context, key, description, content string) error
	Update(ctx context.Context, key, description, content string) error
	Delete(ctx context.Context, key string) (existed bool, err error)
}

Create returns ErrExists for a key that is already there. Update replaces what a key holds, and writes one that holds nothing yet on a backend that does not enforce read-before-update. The jetstream backend does enforce it by default, so an Update of a key this scope never read returns ErrStale rather than writing it.

An implementation must be safe for concurrent use by independent processes sharing one backing store, and must validate the key before touching that store. Info is a required method rather than an optional capability, so every backend reports its name and location.

Item
Key and description only. The body is always a separate Read.
Info.Backend
The registered backend name, which lands on a telemetry span.
Info.Location
An operator-configured identifier, never a filesystem path, a URL carrying userinfo, or a credential. The file backend returns an empty string for exactly that reason.
Scope
One run's record of which keys it has read and at which revision. A nil *Scope is valid and authorizes no overwrite, so every backend uses it without a nil check.

Limits live in store.go: 200 runes of key, 500 runes of description, 64 KiB of content, 1024 entries. MaxEntryBytes adds twice the description budget plus 64 bytes on top of the content cap, because YAML may quote and escape every byte of a description.

Writing a memory

  1. Validate before storing Every backend calls memory.ValidateWrite first: the key charset, the description after normalization, and the 64 KiB content cap. The normalized description is what gets persisted; the raw one is discarded.
  2. Count on create only An overwrite replaces an entry that already counted, so CheckCapacity runs on the create path alone.
  3. Serialize once memory.Serialize writes the YAML header with a real marshaller, so a description containing a colon, a quote or a leading dash cannot corrupt it.
  4. Store atomically The file backend stages a temp file and links it for a create or renames it for an overwrite. JetStream calls kv.Create, or the revision-checked kv.Update.
  5. Answer the model in its own terms ErrExists becomes a structured refusal that names the colliding memory's description, found by an extra read, so the model can decide without spending another tool call. ErrStale becomes an instruction to read the key and retry with overwrite: true.
memory_writemodel tool callValidateWritekey, size, descSerializeheader plus bodybackendCreate or Updaterun scopekey to revisionErrExists or ErrStale, carrying the reason the model needsgrants the overwrite
A write is validated in the shared package, stored by the backend, and refused with a reason the model can act on.

Keys and read-before-update

Legal keys match ^[A-Za-z0-9._=-]+$, the intersection of legal NATS KV key characters and safe filename characters. The slash is excluded so a key maps one to one onto a flat filename with no path separator to escape. ValidateKey also refuses a leading or trailing dot and any .., and every method in both backends calls it, including Read and Delete. The file backend re-validates when listing, so a hand-planted file whose stem is not a legal key stays invisible.

The JetStream backend requires a read before an update. Only Read records a revision in the run’s Scope. List and the start-of-run index also read values, but they read them to build an index rather than on the model’s behalf, so seeing a key in the index grants no authority to overwrite it. A successful create does grant it, since the model just wrote that value. A delete drops the revision, so a stale one cannot authorize an overwrite of a key that was re-created in between.

The scope is resolved per call rather than captured at construction, so one shared store serves many concurrent runs and each keeps its own. Across a suspend and resume the scope is stored in the journal: the runner writes Scope.Snapshot() as an optional record after the terminal record, replay takes newest-wins, and resume seeds the scope back. It survives a tool-set change, because revisions record what the store held rather than what an operator agreed to.

The two backends

filejetstream
Unitone .md file per key under memory/<identity>one KV value per key, <prefix>.<key>
Namespacinga directory per identitya key prefix, defaulting to the identity
Createos.Link, which fails if the name existskv.Create
Overwriteos.Rename, last write winsrevision-checked kv.Update by default
ErrStalenever returnedreturned when the scope knows no revision for the key, or the key changed since it was read
Listingread the directory, then one read per fileone server-side watcher pass, filtered to the prefix
Startup checkcreate the directory at mode 0700bind the bucket, reject a missing one, a TTL, or an undersized one
Info.Locationemptythe bucket name

The prefix option is a pointer so an omitted prefix, which defaults to the identity, stays distinguishable from an explicit empty string, which means a flat keyspace.

Load-bearing decision

Memory content is data, not instruction. The system note ends on that sentence, and the start-of-run index repeats it and fences the entries in a <memory-index> block. Descriptions are normalized to a single line at write time and passed through util.SanitizeForTerminal again at render time, which strips ANSI escapes as well. A value written by a hand-editing operator, or one written before the normalizer existed, is caught at render.

Load-bearing decision

The file backend opens with O_NOFOLLOW and then stats the returned descriptor, rejecting anything that is not a regular file. Content is read from that descriptor and never by re-opening the path, because a second open by name would resolve the path again and follow whatever was swapped in since. On Windows the flag is a no-op and the defense rests on the stat plus the privilege required to create a symlink.

Failing at run start

A JetStream bucket with any non-zero TTL is a construction failure, not a degraded run, because stored memories would silently expire. A positive MaxValueSize below MaxEntryBytes is refused for the same reason, and the check uses MaxEntryBytes rather than the content cap because the stored value is body plus frontmatter. A missing bucket produces a copy-pasteable nats kv add command. The backend binds and never creates, so the operator owns the durability policy. The bind runs under a ten second timeout, so a wrong bucket name surfaces at run start rather than hanging.

Strict option decoding is centralized in DecodeOptions, so an unknown key in a backend’s options block fails identically for every backend and the rule cannot drift. If the operator names a backend in config and an injected store reports a different one, the run refuses with an error naming both; naming no backend leaves the choice to the caller.

Read-only memory

With read_only set, the write and delete tools are not registered and the system note does not mention them, because the model spends a call on any tool it can see. The setting exists for a fleet endpoint that takes caller-supplied prompt text, which can otherwise be talked into planting something a later run reads back as its own note.

All four memory tools carry an empty ExposeSpec, which keeps them off the MCP and A2A surfaces. The builtin constructor panics on a nil spec, so that decision has to be made explicitly for every tool.

Next

Memory is what the model writes. For what the operator supplies, continue to Knowledge. For how revisions survive a suspend, see Durable state.