Let an agent keep a memory, through the same gate as any other write
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled

The write trigger, which was the open question. Three answers were available
and two are worse.

A second model call after each run, asked to extract durable facts, judges well
and costs an entire extra call against a ceiling of 8,000 tokens a minute — on
every run, most of which have nothing worth keeping. A heuristic in the loop is
cheap and remembers the wrong things: the shape of a run says nothing about
whether a fact outlives it, so the table fills with restatements of rows the
database already holds.

So: a tool. It costs nothing extra, because the model is already mid-run with a
catalogue in front of it and remembering is one more call it may make when it
has just learned something that will not be in the records next time. It is
automatic in the sense that matters — nobody types "remember this" — and it is
visible in the trajectory, which an extraction pass would not be.

IT IS A CONFIRMED WRITE, and that is the invariant working rather than an
obstacle. EffectWrite forces RequiresConfirmation, and this tool stores
personal data that will shape later hiring answers — the most consequential
thing a model can do here short of assigning somebody to a shift. A reader sees
the sentence before it is kept, who it is about, that an agent and not a person
decided it, and that it expires in ninety days. A memory about a person also
carries a warning that says so and says it can be erased.

If a deployment later wants workspace facts kept without asking, the honest
change is a SECOND tool scoped to workspace subjects. Loosening this one would
quietly make personal memories unconfirmed too, which is the whole thing this
gate is for.

Author is always "model" and is not a field the model can set: an inference
must never be readable later as though a person had written it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 20:05:02 +05:30
parent 8c51c22c86
commit 57abafe73b
2 changed files with 340 additions and 0 deletions

View File

@@ -0,0 +1,149 @@
package tools
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/memory"
)
type recordingStore struct {
writes []memory.Write
err error
}
func (r *recordingStore) Remember(_ context.Context, _ authctx.Identity, w memory.Write) (string, error) {
if r.err != nil {
return "", r.err
}
r.writes = append(r.writes, w)
return "mem-1", nil
}
func rememberCtx() Context {
return Context{
Principal: authctx.Identity{UserID: "u1", OrgID: "o1", Role: "admin"},
RunID: "run_abc",
}
}
// I4. A memory stores personal data that shapes later hiring answers, so it
// goes through the same gate as any other write — and the registry is what
// enforces that, not this tool's good intentions.
func TestRememberIsAConfirmedWrite(t *testing.T) {
tool := Remember(&recordingStore{})
if tool.Effect != EffectWrite {
t.Errorf("Effect = %q, want write", tool.Effect)
}
r := NewRegistry()
r.MustRegister(tool)
registered := r.Catalogue()
var found bool
for _, info := range registered {
if info.Name == "remember" {
found = true
if !info.RequiresConfirmation {
t.Error("remember was registered without a confirmation gate")
}
}
}
if !found {
t.Fatal("remember did not register")
}
}
// A model may not claim a person wrote something. The distinction is what
// keeps "the agent inferred X" from being read back later as "X".
func TestARememberedMemoryIsAlwaysAttributedToTheModel(t *testing.T) {
store := &recordingStore{}
tool := Remember(store)
res := tool.Handler(context.Background(), rememberCtx(),
json.RawMessage(`{"text":"This venue staffs on Thursdays.","subject":"workspace"}`))
if res.Error != nil {
t.Fatalf("the write failed: %+v", res.Error)
}
if len(store.writes) != 1 {
t.Fatalf("got %d writes, want 1", len(store.writes))
}
if store.writes[0].Author != memory.AuthorModel {
t.Errorf("Author = %q, want model", store.writes[0].Author)
}
}
// Without the run id, a memory that shaped an answer cannot be traced to where
// it came from, and "why did it say that" stops being answerable.
func TestARememberedMemoryCarriesItsRun(t *testing.T) {
store := &recordingStore{}
Remember(store).Handler(context.Background(), rememberCtx(),
json.RawMessage(`{"text":"Thursdays are short-staffed.","subject":"workspace"}`))
if len(store.writes) == 0 || store.writes[0].SourceRunID != "run_abc" {
t.Error("the memory does not name the run that wrote it")
}
}
func TestAnInventedSubjectIsRefused(t *testing.T) {
store := &recordingStore{}
res := Remember(store).Handler(context.Background(), rememberCtx(),
json.RawMessage(`{"text":"x","subject":"everything"}`))
if res.Error == nil {
t.Error("an invented subject was accepted")
}
if len(store.writes) != 0 {
t.Error("a refused memory still reached the store")
}
}
func TestAMemoryWithNoWordsIsRefused(t *testing.T) {
store := &recordingStore{}
res := Remember(store).Handler(context.Background(), rememberCtx(),
json.RawMessage(`{"text":" ","subject":"workspace"}`))
if res.Error == nil || len(store.writes) != 0 {
t.Error("an empty memory was accepted")
}
}
/* ── What a person is shown before agreeing ──────────────────────────────── */
// The two questions somebody needs answered before keeping a memory are
// "about whom" and "who decided this".
func TestTheConfirmationSaysWhatIsKeptAndWhoDecided(t *testing.T) {
c, bad := Remember(&recordingStore{}).Confirm(context.Background(), rememberCtx(),
json.RawMessage(`{"text":"Prefers Bay Area venues.","subject":"user","subject_id":"u9"}`))
if bad != nil {
t.Fatalf("the confirmation was refused: %+v", bad)
}
flat := c.Summary
for _, d := range c.Details {
flat += " " + d.Label + "=" + d.Value
}
for _, want := range []string{"Prefers Bay Area venues.", "user u9", "an agent, not a person", "90 days"} {
if !strings.Contains(flat, want) {
t.Errorf("the card does not state %q:\n%s", want, flat)
}
}
}
// A personal memory is flagged as such, because the thing being agreed to is
// different in kind from remembering an opening time.
func TestAPersonalMemoryWarnsAndAWorkspaceFactDoesNot(t *testing.T) {
tool := Remember(&recordingStore{})
personal, _ := tool.Confirm(context.Background(), rememberCtx(),
json.RawMessage(`{"text":"Was late twice.","subject":"candidate","subject_id":"c1"}`))
if len(personal.Warnings) == 0 ||
!strings.Contains(strings.Join(personal.Warnings, " "), "personal data") {
t.Errorf("a memory about a person carries no warning: %+v", personal.Warnings)
}
if !strings.Contains(strings.Join(personal.Warnings, " "), "erased") {
t.Error("the warning does not say the memory can be erased")
}
operational, _ := tool.Confirm(context.Background(), rememberCtx(),
json.RawMessage(`{"text":"Thursdays are short-staffed.","subject":"workspace"}`))
if len(operational.Warnings) != 0 {
t.Errorf("an operational fact was warned about: %+v", operational.Warnings)
}
}