Add long-term memory: org-scoped, attributed to a subject, and expiring
The first store whose contents are deliberately fed back into a prompt, which
makes it a different kind of table from everything around it. Org-scoped by
product decision: a memory written while one recruiter worked is available to
the next, because a workspace's view of its own hiring should not reset per
seat.
It remembers both kinds asked for — operational facts and observations about
named people — and the second is why most of this code is provenance rather
than payload. "This applicant seemed unreliable", stored automatically and
read into a later hiring answer, is profiling under GDPR and is the artefact an
employment claim is built on. The only thing that makes holding it defensible
is that it can be listed, shown and erased, so:
- subject_type and subject_id are mandatory for anything personal, refused at
the door rather than defaulted, because a memory about somebody that names
nobody cannot be shown to them or deleted for them;
- Held() answers a subject access request and Forget() answers an erasure,
each in one statement, and Forget is a soft delete so the erasure itself is
recorded;
- every memory carries its author and the run that wrote it, so "why did it
say that" survives memory entering the picture, and an inference is never
read back as if a person had written it;
- everything expires. Ninety days by default: a hiring workspace changes
shape over a quarter, and a stale fact read as a current one is worse than
no memory at all.
The block the model sees is fenced and labelled on the same terms as retrieved
documents, for a stronger reason — a memory is text this system wrote about its
own users, so a model that treated it as an instruction would let one run steer
every run after it. It states the origin of each line and says plainly that a
memory is never a reason on its own to accept or reject anybody. That sentence
is pinned by a test.
Recall is semantic where an embedder exists and newest-first where it does not,
and says which happened rather than quietly returning recency. Five memories by
default: this competes for the same prompt as the tool catalogue and the
retrieved block, against a ceiling of 8,000 tokens a minute.
Migration 000017 is WRITTEN AND NOT APPLIED. Nothing is wired into the runtime
yet — this is the store and its rules, reviewable on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
101
go-api/internal/memory/memory_test.go
Normal file
101
go-api/internal/memory/memory_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The rule that makes storing an observation about a person defensible: it can
|
||||
// be found. A memory about somebody that names nobody cannot be shown to them
|
||||
// on request and cannot be erased for them, so it is refused at the door.
|
||||
func TestAPersonalMemoryWithoutASubjectIsRefused(t *testing.T) {
|
||||
for _, subject := range []Subject{SubjectCandidate, SubjectUser} {
|
||||
w := Write{SubjectType: subject, Text: "seemed unreliable", Author: AuthorModel}
|
||||
if err := w.Validate(); err != ErrSubjectRequired {
|
||||
t.Errorf("%s without a subject id: got %v, want ErrSubjectRequired", subject, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A workspace fact has no personal subject and must not be made to invent one.
|
||||
func TestAWorkspaceMemoryNeedsNoSubject(t *testing.T) {
|
||||
w := Write{SubjectType: SubjectWorkspace, Text: "This venue staffs on Thursdays.", Author: AuthorModel}
|
||||
if err := w.Validate(); err != nil {
|
||||
t.Errorf("a workspace fact was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyMemoryIsRefused(t *testing.T) {
|
||||
w := Write{SubjectType: SubjectWorkspace, Text: " ", Author: AuthorModel}
|
||||
if err := w.Validate(); err != ErrEmpty {
|
||||
t.Errorf("got %v, want ErrEmpty", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A memory is a sentence. The long form of something belongs in the corpus,
|
||||
// which has ingestion review and search; this table has neither.
|
||||
func TestAMemoryLongerThanASentenceIsRefused(t *testing.T) {
|
||||
w := Write{SubjectType: SubjectWorkspace, Text: strings.Repeat("x", MaxTextRunes+1), Author: AuthorModel}
|
||||
if err := w.Validate(); err == nil {
|
||||
t.Error("an over-long memory was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnknownSubjectOrAuthorIsRefused(t *testing.T) {
|
||||
if err := (Write{SubjectType: "anything", Text: "x", Author: AuthorModel}).Validate(); err == nil {
|
||||
t.Error("an invented subject type was accepted")
|
||||
}
|
||||
if err := (Write{SubjectType: SubjectWorkspace, Text: "x", Author: "nobody"}).Validate(); err == nil {
|
||||
t.Error("an invented author was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// Everything written decays. A fact with no end date is read back long after
|
||||
// it stopped being true.
|
||||
func TestTheDefaultTTLIsBounded(t *testing.T) {
|
||||
if DefaultTTL <= 0 || DefaultTTL > 365*24*time.Hour {
|
||||
t.Errorf("DefaultTTL = %v; a memory must expire, and within a year", DefaultTTL)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── What the model is shown ─────────────────────────────────────────────── */
|
||||
|
||||
func TestRenderFencesAndLabelsMemories(t *testing.T) {
|
||||
out := Render([]Record{
|
||||
{SubjectType: SubjectWorkspace, Author: AuthorModel, Text: "Thursdays are short-staffed."},
|
||||
})
|
||||
for _, want := range []string{"<memory>", "</memory>", "never", "instructions"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("the memory block does not contain %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An inference and a recruiter's note are different kinds of claim. Flattening
|
||||
// them lets "the model thought X" be read back later as "X".
|
||||
func TestRenderSaysWhetherAMemoryWasInferredOrWritten(t *testing.T) {
|
||||
out := Render([]Record{
|
||||
{SubjectType: SubjectCandidate, SubjectID: "c1", Author: AuthorModel, Text: "A"},
|
||||
{SubjectType: SubjectCandidate, SubjectID: "c2", Author: AuthorPerson, Text: "B"},
|
||||
})
|
||||
if !strings.Contains(out, "inferred by an agent") || !strings.Contains(out, "noted by a person") {
|
||||
t.Errorf("the origin of each memory is not stated:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The block says plainly that a memory is not a reason to reject somebody.
|
||||
// This is the sentence that keeps a remembered impression from being read as a
|
||||
// decision, so it is pinned by a test rather than left to an edit.
|
||||
func TestRenderRefusesToLetAMemoryDecide(t *testing.T) {
|
||||
out := Render([]Record{{SubjectType: SubjectCandidate, SubjectID: "c1", Author: AuthorModel, Text: "A"}})
|
||||
if !strings.Contains(out, "never a reason on their own to accept or reject") {
|
||||
t.Errorf("the block does not say a memory cannot decide:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderIsEmptyWhenThereIsNothingToRemember(t *testing.T) {
|
||||
if Render(nil) != "" {
|
||||
t.Error("an empty memory set must add nothing to the prompt")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user